diff --git a/.github/workflows/local-profile.yml b/.github/workflows/local-profile.yml index 8b121d26..fbe0cd36 100644 --- a/.github/workflows/local-profile.yml +++ b/.github/workflows/local-profile.yml @@ -9,12 +9,23 @@ on: - 'src/Civ13/RestartPolicy.php' - 'src/Civ13/Civ13.php' - 'src/Civ13/GameServer.php' + - 'src/Civ13/AdminListRenderer.php' + - 'src/Civ13/AdminSyncSnapshotGate.php' + - 'src/Civ13/AdminSyncCoordinator.php' + - 'src/Civ13/AdminMemberSnapshotProvider.php' + - 'src/Civ13/AdminCacheSyncTrigger.php' + - 'src/Civ13/AdminFileSync.php' + - 'src/Civ13/PortableProfile.php' + - 'src/Civ13/HostedVerificationSource.php' + - 'src/Civ13/HostedVerificationSnapshot.php' + - 'src/Civ13/Verifier.php' - 'src/Civ13/Slash.php' - 'src/Civ13/Panels.php' - 'src/Civ13/MessageCommand/Commands/AdminListUpdate.php' - 'src/Civ13/MessageCommand/Commands/Civ13GameServerMapSwap.php' - 'src/Civ13/HttpServiceManager.php' - 'local-bot.php' + - 'bot.php' - 'tests/local/**' - 'scripts/local/**' - '.github/workflows/local-profile.yml' @@ -42,6 +53,10 @@ jobs: extensions: sodium coverage: none - run: php tests/local/run.php + - run: php tests/local/admin-sync-projection.php + - run: php tests/local/admin-file-sync-profile.php + - run: php tests/local/admin-file-writer.php + timeout-minutes: 2 - run: php -l local-bot.php supervision: if: github.event_name != 'workflow_dispatch' || !inputs.native_smoke @@ -59,6 +74,14 @@ jobs: coverage: none - run: composer install --no-interaction --prefer-dist --no-progress - run: php tests/local/library.php + - run: php tests/local/admin-sync-coordinator.php vendor/autoload.php + - run: php tests/local/admin-member-snapshot.php vendor/autoload.php + - run: php tests/local/admin-cache-sync.php vendor/autoload.php + timeout-minutes: 2 + - run: php tests/local/admin-file-sync.php vendor/autoload.php + timeout-minutes: 2 + - run: php tests/local/admin-verification-refresh.php vendor/autoload.php + timeout-minutes: 2 - run: php tests/local/opendream.php - run: php tests/local/restart-command.php vendor/autoload.php - run: php tests/local/lifecycle.php vendor/autoload.php @@ -72,6 +95,25 @@ jobs: - run: php tests/local/unban-commands.php vendor/autoload.php - run: php tests/local/adminlist-consumers.php vendor/autoload.php - run: php tests/local/lifecycle-consumers.php vendor/autoload.php + admin-sync-windows: + if: github.event_name != 'workflow_dispatch' || !inputs.native_smoke + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4', '8.5'] + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer + coverage: none + - run: composer install --no-interaction --no-plugins --no-scripts --no-dev --prefer-dist --no-progress + - run: php -d allow_url_fopen=0 -d disable_functions=fsockopen,pfsockopen,stream_socket_client,curl_exec,curl_multi_exec,socket_create,exec,shell_exec,system,passthru,proc_open,popen tests/local/admin-cache-sync.php vendor/autoload.php + - run: php -d allow_url_fopen=0 -d disable_functions=fsockopen,pfsockopen,stream_socket_client,curl_exec,curl_multi_exec,socket_create,exec,shell_exec,system,passthru,proc_open,popen tests/local/admin-file-sync.php vendor/autoload.php + - run: php -d allow_url_fopen=0 -d disable_functions=fsockopen,pfsockopen,stream_socket_client,curl_exec,curl_multi_exec,socket_create,exec,shell_exec,system,passthru,proc_open,popen tests/local/admin-verification-refresh.php vendor/autoload.php windows-exe-smoke: if: github.event_name == 'workflow_dispatch' && inputs.native_smoke runs-on: windows-latest diff --git a/bot.php b/bot.php index ecfe7686..3c4c9781 100644 --- a/bot.php +++ b/bot.php @@ -124,6 +124,16 @@ function loadEnv(string $filePath, bool $explicit = false): array if (! $autoloader = require file_exists(__DIR__.'/vendor/autoload.php') ? __DIR__.'/vendor/autoload.php' : $baseDir.'/vendor/autoload.php') { throw new \Exception('Composer autoloader not found. Run `composer install`, or keep the binary inside the project directory.'); } +// Pure admin-sync preflight uses existing GameServer rank keys after Composer is available. +if ($portable !== null) { + $adminSyncOptions = PortableProfile::runtimeOptions($portable); + $adminSyncSettings = $adminSyncOptions['admin_file_sync']; + if ($adminSyncSettings['enabled'] === true) { + \Civ13\AdminFileSync::validateConfiguration($adminSyncSettings, $adminSyncSettings['admin_file'] ?? '', + $adminSyncOptions['civ13_guild_id'], $adminSyncOptions['role_ids']); + } + unset($adminSyncOptions, $adminSyncSettings); +} $loadedNames = loadEnv($portable['env_file'] ?? $baseDir.'/.env', $portable !== null); if ($portable !== null && (!in_array('TOKEN', $loadedNames, true) || !getenv('TOKEN'))) { throw new \RuntimeException('Separate profile environment must explicitly supply TOKEN'); } diff --git a/docs/admin-cache-sync.md b/docs/admin-cache-sync.md new file mode 100644 index 00000000..be567a6e --- /dev/null +++ b/docs/admin-cache-sync.md @@ -0,0 +1,53 @@ +# Cache-driven admin-file synchronization (inactive) + +This application-only adapter trusts DiscordPHP's maintained member cache. Member/role events request a new projection of that cache, not a REST fetch. It does not create a polling service, HTTP client or second membership store. No bootstrap registers its listeners and no configuration is enabled by this source slice. + +## Reuse existing bot behavior + +The existing `GameServer::adminlistUpdate()` uses `ADMIN_PERMISSIONS` precedence, required configured roles and LF-delimited output. `updateFilesFromMemberRoles()` joins `verifier->verified` to `Verifier::getVerifiedMember()`, which resolves the member from the target guild's repository. Preserve that cache lookup and rank policy. The pure `AdminListRenderer` expresses the same join/precedence with selected-output validation; `AdminFileWriter` supplies the existing-file guarded commit boundary. + +Do not enable a disabled GameServer merely to call its current admin update method: it has unrelated activation behavior. Do not remove `Verifier::afterConstruct()`'s read-only early return: its legacy member listeners also mutate whitelist/factions and roles. Future integration must register only the dedicated admin trigger and use the explicitly configured `admin_file`, independently of those mutation listeners. `PortableProfile` still accepts one explicit TEST1714 server; this proposal does not invent two-server profile support or alter game-file consumption. + +## Cache and supported event contracts + +Inspected locked DiscordPHP: `212bed1e0481dab3721795e0926bf133d34a674e`. + +- Existing `bot.php` sets `loadAllMembers => true` and `Intents::getAllIntents()`. `init(Discord)` is emitted once after the library's initialization flow; deprecated `ready` is redundant. The adapter accepts the already initialized target-guild cache at init after the injected availability check. There is no extra initial member request. +- `reconnected(Discord)` is emitted both by RESUMED handling and by READY during reconnect. Uppercase READY/RESUMED are internal fallback handlers, not ordinary public dispatch hooks to assume. A reconnect closes the application readiness barrier and invokes the existing supported `members->freshen()` (no arguments, paginates members) and `roles->freshen()` promises once. +- `GuildMemberUpdate` updates its member and calls repository `set()` before `emitDispatchResult()` emits `(newMember, Discord, oldMemberOrNull)`. `set()` updates in-memory items synchronously. `GuildMemberRemove` yields `cachePull()`/delete before emitting removal; the cache wrapper removes the in-memory item on successful deletion. Thus normal event listeners read the updated cache. +- Role create/update/delete handlers likewise update their repositories before the dispatched callback. Role deletion may supply a Role or a raw payload with `guild_id`. Member old-role data is optional; the trigger conservatively requests projection for target-guild member updates/removals rather than suppressing unknown changes. +- GUILDS and privileged GUILD_MEMBERS intents cover these role/member events. Live intent authorization and event settings remain deployment checks, not modifications in this patch. + +Source references: [Discord event emission/lifecycle](https://github.com/discord-php/DiscordPHP/blob/212bed1e0481dab3721795e0926bf133d34a674e/src/Discord/Discord.php), [member update](https://github.com/discord-php/DiscordPHP/blob/212bed1e0481dab3721795e0926bf133d34a674e/src/Discord/WebSockets/Events/GuildMemberUpdate.php), [member removal](https://github.com/discord-php/DiscordPHP/blob/212bed1e0481dab3721795e0926bf133d34a674e/src/Discord/WebSockets/Events/GuildMemberRemove.php), [repository cache operations](https://github.com/discord-php/DiscordPHP/blob/212bed1e0481dab3721795e0926bf133d34a674e/src/Discord/Repository/AbstractRepositoryTrait.php), [member refresh](https://github.com/discord-php/DiscordPHP/blob/212bed1e0481dab3721795e0926bf133d34a674e/src/Discord/Repository/Guild/MemberRepository.php). + +## Narrow application contracts + +`AdminCacheSyncTrigger` owns readiness and an invalidation epoch, not member records. Its stable listener map is returned without registration. Bind before initialization, or explicitly call `bootstrapReady()` only when the application already knows initialization completed. Remove only these exact closures when detaching. `stop()` cancels only its owned timer(s) and fences future projections; configuration changes invalidate the epoch immediately, including while debounce is pending. A failed configuration callback remains fenced until a successful explicit configuration update; reconnect alone cannot approve a failed configuration. + +Use a dedicated existing `AdminSyncCoordinator`; all requests for that coordinator must pass through this adapter. Idle events share one bounded debounce window; events during reconciliation immediately advance its dirty generation. The coordinator's member callback reads the current guild cache, tags the value projection with `cache_epoch` and its supplied `generation`, and performs no member fetch. Use the existing verified-record/member lookup instead of trusting an event payload as the entire member list. + +`AdminSyncSnapshotGate::renderCached()` accepts trusted current `{ready, epoch}` from the trigger at every projection and precommit. It requires matching captured cache epoch and coordinator generation, valid required guild roles, and a fresh successful verification snapshot. It imposes **no member-cache age/TTL**, and does not fabricate a Gateway-connected attestation. A ready cache with no eligible admins produces the valid empty authoritative file; uninitialized, reconnect-refreshing or refresh-failed state cannot clear it. + +The injected reconnect refresh should compose the existing promises, for example `React\Promise\all([$guild->members->freshen(), $guild->roles->freshen()])`, without starting a new authenticated client. During refresh, member/role events are coalesced; successful completion projects the then-current cache. A newer reconnect/configuration transition invalidates an outstanding refresh attempt; its late completion cannot reopen readiness. An actual reconnect received while refreshing records one pending refresh: after the old request drains, exactly one coalesced refresh starts for the newer connection. No parallel refreshes occur. Failure or timeout alone never creates follow-up work, and a timed-out request that never settles keeps the barrier closed. A result deadline/failure leaves the adapter not-ready and preserves the file. It does not cancel the library's underlying HTTP requests, silently retry them, or treat an unresolved refresh as complete. The pinned member refresh has no own deadline, can remain pending after a nested cache failure, and retains its old cache if its initial page is empty; this is an integration limitation, not a reason to replace the normal maintained cache or change libraries here. Reconnection refresh is not an atomic transaction with concurrent events. + +## Activation remains separate + +No token/environment reads, Discord registration, member refresh, writer invocation, bot startup or game operation occur merely by constructing this source foundation. Required runtime decisions remain: explicit Windows/D: configuration, admin-only enablement, approved role map, exclusive owner, successful initialization, trusted verification source, guarded writer and shared game consumption. A one-time reviewed verification snapshot does not authorize future unauthenticated plain-HTTP verifier changes. + +Superseded unpublished preparations are **not part of this cache path**: the new `AdminRestMemberClient`, REST transport tests, per-event completed-pagination provider integration, application membership-age TTL, and the prior REST-based event-trigger packet. Existing unused draft foundations need not be wired to implement cache-driven synchronization. + +## Offline test + +```sh +php -l src/Civ13/AdminCacheSyncTrigger.php +php -l src/Civ13/AdminSyncSnapshotGate.php +php tests/local/admin-cache-sync.php vendor/autoload.php +``` + +Existing dependency-installed supervision runs synthetic cache/event tests on PHP 8.3/8.4/8.5. Tests use injected cache state, timers, refresh promises and a memory-only writer—not a live bot or admin file. + +## Dirty-work continuation + +A coordinator burst still has a finite pass budget. If an already-received event dirties its final pass, the terminal outcome explicitly sets `continuation_required=true`. The cache trigger preserves this one coalesced intent, including synchronous request reentrancy, and services it through the normal debounce only after pending work drains and readiness permits. This is continuation of received work, not periodic polling or an error retry. New requests consume older continuation intent. Stop, reconnect and invalid configuration still fence writes. + +Generic writer `superseded`/`blocked`, failed or integrity-uncertain outcomes do not request continuation. `retry_required` alone is not an automatic-retry instruction. A successful continuation does not schedule another unless a new event dirties that burst too; sustained real event traffic is paced by the debounce. The injected one-shot scheduler must defer callbacks until after it returns; a synchronous callback is rejected instead of creating a recursive busy loop. diff --git a/docs/admin-file-writer.md b/docs/admin-file-writer.md new file mode 100644 index 00000000..68981f79 --- /dev/null +++ b/docs/admin-file-writer.md @@ -0,0 +1,57 @@ +# Existing admin file writer + +`Civ13\Server\AdminFileWriter` is a dedicated synchronous adapter for an explicitly selected, existing, operator-controlled local file. It is not registered with the bot or activated by configuration. The shared `Server/Ipc::replace()` method and its existing callers are unchanged. + +## Explicit boundary + +Construct the adapter with an absolute canonical local path and a byte budget of 1–16,777,216 bytes. It has no default path. The constructor validates strings only. Windows requires a drive-qualified path and rejects UNC/device forms and alternate-stream syntax. Relative components, redundant separators and directory-only roots are refused. + +Invoke it with candidate bytes, a nonnegative generation and the coordinator's pure synchronous `canCommit` callback. The byte budget bounds both candidate and existing content. Oversized or unavailable existing files are blocked, not automatically truncated to make them fit. + +The adapter never creates a missing file or parent directory, replaces an inode, changes permissions or ownership, creates links, retries a lock/write, or restores an old snapshot. Provisioning a destination is a separate operator action. The intended canonical file is bot-owned; a game deleting/recreating its input remains an unresolved integration constraint. + +## Lock and commit sequence + +1. Check the guard before filesystem access. +2. Inspect the existing regular file and canonical resolved path, then open `r+b`: missing paths are not created and opening does not truncate. +3. Attempt `LOCK_EX | LOCK_NB` once. Contention and other open/lock failures return `blocked` without mutation. +4. Under lock, compare pre-open, opened-handle and current-path device/inode evidence; reject missing/zero identity evidence and unsupported 32-bit PHP. Recheck the guard under lock. +5. Read bounded existing bytes, recheck path identity, rewind and call the guard again immediately before mutation or an `unchanged` acknowledgement. A false/throwing guard returns `superseded`; no asynchronous wait occurs between this final guard and writing. +6. For different bytes, complete bounded chunk writes, truncate to the exact length and flush. Empty bytes intentionally truncate. Verify available pathname identity again before returning `updated`. Always release the lock and close the handle. + +`unchanged` means exact existing bytes were checked under lock and the final guard passed. It is not a successful no-op caused by missing configuration. + +## Failure semantics + +`blocked` is a known no-mutation refusal and is now accepted by the coordinator. A writer-level reason identifies the refused phase without exposing file contents. The coordinator reports the blocked write phase and preserves any previously confirmed writes in that reconciliation burst. + +Once a write/truncate/flush may have changed the file, errors throw rather than returning `blocked`. The coordinator reports `failed` with integrity uncertainty. A zero/false/thrown write, failed truncate, failed flush or detected pathname change never reports `updated`. No rollback or hidden retry is performed. Retrying after a failure requires an explicit subsequent reconciliation decision. + +The protected write/truncate/flush primitives form a narrow deterministic temporary-test seam. Normal production behavior calls PHP filesystem functions directly; no generic filesystem abstraction or alternate path resolver is introduced. + +## Platform and concurrency limits + +In-place writing keeps the existing file identity and does not change ACLs or ownership, but it is not transactional or crash-atomic. Noncooperating readers/writers may observe or cause partial contents. A process retaining an earlier open handle must still refer to the same file after a successful update. + +Nonblocking lock acquisition does not make open, read, write or flush nonblocking, nor guarantee a wall-clock I/O deadline. Windows sharing restrictions can cause a refusal; do not change security settings or weaken identity checks to bypass it. + +Modern PHP supplies Windows file identity fields, but an unavailable/zero identity or mismatched evidence fails closed. Windows mode bits do not prove ACL equality. The temporary tests check file identity and mode where observable; they do not claim a comprehensive native ACL audit. + +Canonical-path, `lstat` and handle checks reject observed symlinks and path mismatches. They are not an atomic no-follow/open-by-handle boundary and do not prove the absence of every Windows reparse-point, parent-directory or path-swap race. Use an operator-controlled directory. Reliable deployment-specific identity/reparse/sharing behavior remains a Windows validation gate; no security-setting changes or symlink workarounds are included. + +The guard must remain pure. It must not mutate the file/path or claim authority without fresh validated source/member evidence. Source authentication, Discord continuity and game-consumption semantics remain separate activation gates. + +## Offline tests + +At the repository root, using PHP 8.3 or newer: + +```sh +php -l src/Civ13/Server/AdminFileWriter.php +php -l tests/local/admin-file-writer.php +php tests/local/admin-file-writer.php +php tests/local/admin-sync-coordinator.php vendor/autoload.php +``` + +The dependency-free filesystem test uses only its owned temporary directory. It covers guard refusal, contention, checked unchanged, LF framing, empty/shorter output, identity, bounded size, missing paths and deterministic partial failures. A temporary symlink rejection fixture runs only if the platform permits creating it; otherwise it prints an explicit skip and leaves writer safeguards unchanged. No privilege or permission setting is changed to make that fixture run. + +The existing Ubuntu/Windows PHP 8.3/8.4 replica matrix runs the file test with a two-minute step timeout. The existing dependency-installed supervision matrix runs the coordinator's `blocked` outcome regression. Neither test activates a live path or game. diff --git a/docs/admin-member-snapshot.md b/docs/admin-member-snapshot.md new file mode 100644 index 00000000..ac5a414f --- /dev/null +++ b/docs/admin-member-snapshot.md @@ -0,0 +1,49 @@ +# Injected admin membership snapshot provider + +`AdminMemberSnapshotProvider` collects bounded, completed pagination into a private snapshot. It is not an atomic Discord snapshot and has no default HTTP client, Discord client, lifecycle observer or runtime wiring. Constructing this foundation does not activate synchronization. + +## Adapter contracts + +Supply all callbacks and limits explicitly: + +- `fetchRoles(guildId)` returns a list of normalized role-ID strings, or its promise. +- `fetchPage(guildId, after, limit)` returns a list of normalized `{id: string, roles: list}` records, or its promise. The provider requests limit 1000. The initial cursor is the string `"0"`; subsequent cursors are the greatest validated member ID received. Only an actual empty list terminates pagination, including after a short nonempty page. +- `observe(guildId)` returns `{guild_id, connected, epoch, generation}`. Connection must be explicitly true; epoch and generation are nonnegative integers. The observer must reliably identify disconnects and changes, not infer continuity from cached member count or an earlier ready event. +- `clock()` returns finite monotonic seconds. Backward movement or deadline expiry fails the snapshot. +- `deadline(delaySeconds, expire)` registers a deadline and returns a callable that cancels that timer. Timer registration failure rejects before fetching. Cancellation here never means transport cancellation. + +The normalized transport contract deliberately does not guess installed Discord `Member` classes or convert live payloads. A later adapter must validate raw HTTP responses before constructing these normalized lists; malformed JSON, errors and absent data are not empty lists. + +`maxPages`, `maxMembers` and `timeoutSeconds` are required. Supported safety bounds are 1–1000 pages, 0–1,000,000 members and a positive finite timeout of at most 300 seconds. The terminal empty page counts against the page budget. Each role list is capped at 1000 entries, and each member page at 1000 records. Choose tighter operational limits for the intended guild; these maxima are not defaults or guild-size assumptions. + +## Completion and failure semantics + +The provider obtains roles before pagination and again after the terminal empty page. Both validated role sets must match. It rejects malformed/duplicate IDs, missing or duplicate role IDs, references to unknown guild roles, duplicate members, stalled/backward cursors and exceeded budgets. IDs are compared as decimal strings, never floats or machine integers. + +Lifecycle epoch, generation, connection and elapsed time are checked before requests and after responses. Any failure rejects without returning partial members. Successful results contain the existing gate fields `guild_id`, `connected`, `complete`, `generation`, `roles`, `members`, plus `epoch`, `pages`, `started_at` and `completed_at`. Times are monotonic seconds, not UTC timestamps. Members are deterministically sorted by numeric string ID. + +A deadline rejects the snapshot and ignores late data. If an injected request remains outstanding, the provider stays busy until it settles; another call rejects rather than overlapping that request. Late settlement only releases the busy gate and never issues more pages or publishes partial authority. A transport that never settles can therefore keep this provider busy indefinitely after the caller has received timeout failure. This is intentional: the provider does not pretend to cancel it. + +Completed pagination is an observation over a time window. Matching role sets and lifecycle generations are conservative consistency checks, not proof of an atomic Discord state or protection against undelivered events. The coordinator still needs fresh precommit validation and a trustworthy invalidation source. + +## Runtime gates remain unresolved + +The locked DiscordPHP HTTP client exposes `Discord::getHttpClient()`, `Http::get()` and `Endpoint::GUILD_MEMBERS`/`GUILD_ROLES`; a later adapter should prefer those existing abstractions over a new authenticated HTTP stack. However, the locked client retries 502/525 internally and its queued Deferred has no transport cancellation hook. A provider timeout must not be described as cancelling those requests or retries. + +The locked Discord client also does not expose its protected connection state through the inspected public getter interface. `init`, `reconnected` and `closed` alone do not report every gateway disconnect. A reliable continuity observer must be established separately; this injected contract does not supply one. No dependency patch, subclass or bootstrap modification is included. + +Source authentication, status/records pairing, approved guild/role configuration, real transport deadlines, error observability and actual file/game consumption remain independent integration gates. Pure callback attestations do not resolve them. + +## Offline verification + +At the repository root with existing dependencies and PHP 8.3 or newer: + +```sh +php -l src/Civ13/AdminMemberSnapshotProvider.php +php -l tests/local/admin-member-snapshot.php +php tests/local/admin-member-snapshot.php vendor/autoload.php +``` + +Synthetic fixtures cover terminal-empty completion, empty guilds, short pages, large string IDs, malformed data, role changes, duplicate/stalled cursors, size/page limits, lifecycle changes, deadlines, late-response draining, reentrant deadline expiry and resolved-subscriber reentrancy. Fetches are injected and no bot bootstrap, `.env`, token, live identity, network or admin-file write is used. + +The existing dependency-installed Ubuntu PHP 8.3/8.4/8.5 supervision job runs this test. No default transport or production continuity guarantee is exercised by that offline coverage. diff --git a/docs/admin-sync-foundation.md b/docs/admin-sync-foundation.md new file mode 100644 index 00000000..a770658c --- /dev/null +++ b/docs/admin-sync-foundation.md @@ -0,0 +1,59 @@ +# Admin synchronization foundation + +`AdminListRenderer`, `AdminSyncSnapshotGate` and `AdminSyncCoordinator` provide an offline-testable foundation for authoritative admin-file synchronization. They are **not wired into production**: no production membership or file adapter is wired, and no Discord listener, HTTP client, configuration switch or game integration is supplied. The injected membership provider and the explicit existing-file writer are separately testable foundations, not activated services. Adding these classes does not activate synchronization or change the portable profile's single-server restriction. + +## Projection and snapshot gate + +Pass the existing ordered `GameServer::ADMIN_PERMISSIONS` map to the renderer. The first matching role wins. Records use literal `|||\n` framing and deterministic ckey ordering. Selected admin ckeys must be canonical lowercase alphanumeric strings; an unsafe selected identity rejects the whole projection rather than silently removing it. The existing hosted identity normalizer still permits unrelated legacy spellings. + +The gate rejects unavailable, stale, pending or mismatched verification data, missing required guild roles, incomplete/disconnected membership and obsolete generations. A valid, complete snapshot with no authorized admins returns an empty string: this is authoritative revocation, not failure. Unavailable or incomplete empty data must never be interpreted as revocation. + +The caller must establish complete membership and pair verification status with its records. Status fields are attestations, not proof of source authentication, integrity or authority. These classes do not resolve source-authentication or trust-policy requirements. + +## Injected coordinator contract + +The coordinator accepts an explicit immutable enabled flag and four callbacks; none has a default runtime implementation: + +- `refresh()` returns `{status, records}` or a promise for that snapshot. +- `members(generation)` returns the complete guild snapshot or a promise for it. +- `project(snapshot, membership, generation)` synchronously returns bytes after all policy and snapshot checks. It must be pure and use the current clock on **every** call, including precommit checks. `AdminSyncSnapshotGate` supplies the validation boundary, not the membership-fetch mechanism. +- `write(bytes, generation, canCommit)` returns `{state: updated|unchanged|superseded|blocked}` or a promise. Immediately before mutation, check `canCommit()` and perform **no asynchronous wait between that check and mutation**. If false, return `superseded` without changing the target. `unchanged` means the target was actually checked, not that work was skipped. `blocked` is reserved for a known pre-mutation refusal, such as lock contention; it must not conceal a possible partial write. + +`canCommit()` checks the current generation, reruns projection to check freshness, requires identical bytes and checks generation again. A writer must honor this contract; the coordinator cannot prevent a dishonest callback from writing or falsely claiming success. + +One pipeline runs at a time. Requests during a burst invalidate older work and share its outcome promise. Dirty work gets another pass, up to `maxPasses` (default 3; range 1–10). Exhaustion returns `superseded` with `retry_required=true`; it does not install a timer or automatically start another burst. The owner must arrange another request where appropriate. + +The pass limit bounds concurrency and retries, **not wall time**. Each asynchronous adapter must enforce its own deadline. A forever-pending adapter stays pending; the coordinator does not start overlapping work or claim cancellation. + +## Outcomes and failure handling + +Results include `state`, `phase`, `generation`, `passes`, `writes`, `last_written_generation`, `retry_required` and `integrity_uncertain`. + +- Disabled performs zero adapter calls and returns `disabled`, not success. +- Refresh/member failures and rejected projections return `blocked`. Synchronous exceptions and rejected promises are handled; a resolved unavailable refresh status is not success. +- Successful empty projection is passed to the writer as empty bytes. +- Delayed refresh/member results from an older generation cannot reach later stages. Events during a writer wait for it to settle before another pass. +- Writer exceptions, rejections or malformed outcomes return `failed` with `integrity_uncertain=true`. They are not silently retried, even if a newer request arrived. +- A confirmed earlier write remains counted if a later pass blocks. The final state describes the latest pass, not a rollback of earlier effects. No filesystem atomicity or partial-write recovery guarantee is made. + +Before integration, define trusted source acquisition, complete membership, reconnect invalidation, approved guild/role configuration, deadline handling, writer serialization/recovery, observability and game-consumption behavior. Retain separate activation controls; do not enable a game server merely to enable file synchronization. + +## Offline tests + +Use PHP 8.3 or newer at the repository root. The projection test needs no Composer packages. The coordinator test uses the existing Composer autoload for React promises and injects all providers/writers; its writes are in-memory only. + +```sh +php -l src/Civ13/AdminListRenderer.php +php -l src/Civ13/AdminSyncSnapshotGate.php +php -l src/Civ13/AdminSyncCoordinator.php +php -l tests/local/admin-sync-projection.php +php -l tests/local/admin-sync-coordinator.php +php tests/local/admin-sync-projection.php +php tests/local/admin-sync-coordinator.php vendor/autoload.php +``` + +Fixtures cover valid empty versus unavailable/incomplete snapshots, rank precedence, unsafe selected identities, shared promises, generation invalidation, freshness at delayed commit, bounded churn, failed writers, earlier writes followed by a blocked pass, and reentrant subscribers. No bot bootstrap, `.env`, live identities, game process or real admin file is used. + +The existing offline workflow runs projection on Ubuntu/Windows PHP 8.3/8.4 and coordinator tests in the dependency-installed Ubuntu PHP 8.3/8.4/8.5 supervision job. Workflow coverage is not evidence that a production provider or writer has been implemented or verified. + +The dedicated existing-file filesystem adapter and its platform limits are described in [Admin file writer](admin-file-writer.md). The legacy `Server/Ipc` writer is unchanged. diff --git a/docs/portable-admin-file-sync.md b/docs/portable-admin-file-sync.md new file mode 100644 index 00000000..48517724 --- /dev/null +++ b/docs/portable-admin-file-sync.md @@ -0,0 +1,50 @@ +# Portable admin-file composition — default disabled + +`AdminFileSync` composes the existing read-only verifier, maintained Discord member cache, cache-event trigger, coordinator, pure renderer/gate and guarded existing-file writer. It introduces no client, transport, polling service, credential discovery, second GameServer or game activation. `local-bot.php` and its isolated replica boundary are unchanged. + +The owner selected continued use of the existing configured HTTP verification feed for this task. This is a source-policy choice, not cryptographic authentication or live activation approval. ReactPHP supports HTTPS; the current endpoint is configured as HTTP. No signing, TLS changes, trust flags or extra validator framework are introduced. + +## Opt-in configuration boundary + +An omitted top-level `admin_file_sync` block maps to `enabled=false`, overriding any same-named value smuggled through the generic `options` array. The disabled block accepts only `enabled: false`. The proposed enabled schema requires exactly: + +- `enabled`: true; +- `max_bytes`: integer 1–16,777,216; +- `debounce_seconds`: finite number greater than zero, at most 5; +- `refresh_timeout_seconds`: finite number greater than zero, at most 300. + +Enabling also requires the existing explicit hosted read-only verification configuration, target guild, nonempty role map, and exactly one server with an explicit absolute `admin_file`. The runtime destination is bound to **that existing server setting**, not a second path in this block. A shared pure configuration validator checks every `GameServer::ADMIN_PERMISSIONS` role mapping and distinct ID. Portable `bot.php` invokes it immediately after Composer autoload, before loading environment credentials or constructing Discord; attachment reuses the same validator. Existing TEST1714/single-server restrictions are unchanged. No server `enabled` flag is changed or required merely for file synchronization. The required `options.role_ids` keys are `Owner`, `Chief Technical Officer`, `Host`, `Head Admin`, `Ambassador` and `Admin`, each mapped to a distinct actual Discord role ID. `options.civ13_guild_id` must identify the intended guild. Keep `verification_source` as `{mode: "read-only", url: "http://civ13.com:55555/verified"}`; `runtimeOptions()` derives `verification_read_only=true` from it. Current IDs, limits and destination must be reviewed rather than copied from synthetic fixtures. + +This schema preparation does not approve an actual path or role map, approve live activation, or establish transport authentication. No live profile file is changed. The intended canonical admin file must already exist and be bot-owned under the approved D: configuration; the writer does not create files or directories. + +## Existing behavior reused + +`Verifier::afterConstruct()` retains its read-only early return, so whitelist/faction/role/thread mutation listeners are not enabled. `GameServer::adminlistUpdate()` and its callers remain unchanged. The new composition reuses their verified-record-to-`getVerifiedMember()` cache lookup and `ADMIN_PERMISSIONS` precedence through `AdminListRenderer`, without activating a GameServer or invoking the legacy method's `touch()`. + +Attachment occurs in Civ13's existing init callback, after the earlier read-only Verifier initialization callback assigns the verifier. Runtime attachment failure logs this feature as inactive and leaves its service unset without aborting unrelated bot initialization; fatal configuration mistakes are rejected by the earlier preflight. The existing `ready` flag supplies the explicit late-ready bootstrap signal: listeners added after init do not wait for another initial event. Normal role/member events use the updated maintained cache. Reconnect alone composes the existing member/role repository `freshen()` calls behind the cache trigger's readiness/deadline barrier. The aggregate waits for both calls to settle before reporting failure, so one early rejection cannot release the trigger's drain gate while the other request still runs. No membership TTL or per-event member request is added. + +Verification status and records are copied together, checking status consistency and record count. Before projection and under the writer's precommit guard, the exact record array and success timestamp must still match; same-count identity replacement is not accepted. Verifier object, target guild and role configuration changes fence work. Configuration changes require stop and explicit recreation, not silent rebinding. Existing HostedVerificationSource normalization and ambiguity checks remain in use, along with selected-output validation, verification freshness and cache/epoch guards. These checks do not authenticate HTTP transport. + +The service stores its exact listener closures. Stop fences commits first, then removes only those listeners and cancels owned trigger timers. Core bot shutdown continues if listener cleanup fails; no cleanup exception is allowed to escape the added destructor path. Existing client, other listeners, game instances and shared IPC behavior are not replaced. + +Optional synchronization notification failures are isolated after successful verification-cache replacement. They emit a fixed feature warning without exception details; even failure of that warning logger cannot reject the otherwise successful core refresh or suppress its ordinary status log. Actual source failures retain their existing unavailable status and do not invoke this notification or retry. + +The already-existing verifier refresh completion also notifies the optional composition after a successful non-coalesced cache replacement, including unchanged identities: the new success timestamp can invalidate an older pending write. Failed and coalesced refresh completions do not schedule new work. This application-owned hook reuses the existing debounce and immediately invalidates a pending stale write; its next snapshot acquisition consumes the already-replaced verification cache without calling `refreshHosted()` again. When the coordinator is already acquiring that verifier refresh, it consumes the completion itself and the notification does not dirty another pass. No polling interval, HTTP request, DiscordPHP change or second identity/member store is introduced. Disabled/unattached, retired-verifier and stopped owners remain inert. Required-role deletion policy is unchanged. + +## Existing source policy + +The composition uses `Verifier::refreshHosted()` and `hostedStatus()` with the explicitly configured read-only endpoint `http://civ13.com:55555/verified`. It does not fetch public verifier data during offline tests, change endpoint configuration, mutate verification records remotely, or remove the verifier's read-only guard. The owner's decision to retain this feed does not authorize starting the bot, enabling file writes or changing local configuration. Future source replacement remains an explicit configuration/review task rather than silent discovery. + +## Shared game consumption is separate + +Writing the single canonical file does not prove Nomads and TDM consume it. Inspected Civ13 `code/modules/admin/admin_ranks.dm` hardcodes `SQL/admins.txt` and deletes/recreates it during `load_admins()`. One physical shared read-only input would need separately reviewed consumer configuration/code and lifecycle behavior. However, one authoritative list can also mean identical validated bytes written to separate explicit existing Gaia/TDM admin files, without changing the loader. That future multi-target design must report partial write outcomes; actual TDM destination and reload behavior remain to be confirmed. Neither alternative is needed to complete this single-target file composition. This slice makes no multi-target, game-loader, hardlink, junction, TDM or runtime changes. + +## Offline checks + +```sh +php tests/local/admin-file-sync-profile.php +php tests/local/admin-file-sync.php vendor/autoload.php +php tests/local/admin-verification-refresh.php vendor/autoload.php +``` + +The first test uses synthetic arrays only and runs in the existing Ubuntu/Windows PHP 8.3/8.4 replica matrix. The composition test uses an inert application/cache/emitter, injected clock and memory-only writer in the dependency-installed supervision PHP 8.3/8.4/8.5 matrix. No test loads bot.php, credentials, live Discord, actual admin files or game servers. diff --git a/src/Civ13/AdminCacheSyncTrigger.php b/src/Civ13/AdminCacheSyncTrigger.php new file mode 100644 index 00000000..970e87d2 --- /dev/null +++ b/src/Civ13/AdminCacheSyncTrigger.php @@ -0,0 +1,305 @@ +5 + || !is_finite($refreshTimeoutSeconds) || $refreshTimeoutSeconds<=0 || $refreshTimeoutSeconds>300) { + throw new \InvalidArgumentException('Explicit guild and bounded timers required'); + } + $this->request=\Closure::fromCallable($request); + $this->cacheAvailable=\Closure::fromCallable($cacheAvailable); + $this->refreshCache=\Closure::fromCallable($refreshCache); + $this->schedule=\Closure::fromCallable($schedule); + $this->report=\Closure::fromCallable($report); + $this->invalidateConfiguration=\Closure::fromCallable($invalidateConfiguration); + $this->observed=new \WeakMap(); + } + + public function isReady(): bool { return $this->enabled && !$this->stopped && $this->configurationValid && $this->ready; } + public function epoch(): int { return $this->cacheEpoch; } + public function state(): array { return ['ready'=>$this->isReady(),'epoch'=>$this->cacheEpoch]; } + + /** Application-owned verified-cache replacement; reuse the existing dirty/debounce path. */ + public function verificationChanged(): void + { + if (!$this->enabled || $this->stopped) { return; } + ++$this->cacheEpoch; + $this->signal(); + } + + /** Exact stable closures for caller-owned registration/removal. Cached prior roles never suppress events. */ + public function listeners(): array + { + if ($this->listeners!==null) { return $this->listeners; } + $guildEvent=function (mixed $part,mixed $discord=null,mixed $old=null): void { + if (!$this->enabled || $this->stopped) { return; } + try { $guild=is_object($part)?($part->guild_id??null):null; } + catch (\Throwable) { $this->status('event-shape'); return; } + if ($guild!==$this->guildId) { return; } + ++$this->cacheEpoch; + $this->signal(); + }; + return $this->listeners=[ + 'init'=>function (mixed $discord=null): void { $this->bootstrapReady(); }, + 'reconnected'=>function (mixed $discord=null): void { $this->reconnect(); }, + 'GUILD_MEMBER_ADD'=>$guildEvent,'GUILD_MEMBER_UPDATE'=>$guildEvent,'GUILD_MEMBER_REMOVE'=>$guildEvent, + 'GUILD_ROLE_CREATE'=>$guildEvent,'GUILD_ROLE_UPDATE'=>$guildEvent,'GUILD_ROLE_DELETE'=>$guildEvent, + ]; + } + + /** Call explicitly only when normal Discord init/loadAllMembers completion is already known. */ + public function bootstrapReady(): void + { + if (!$this->enabled || $this->stopped || $this->initialized) { return; } + $this->initialized=true; + $epoch=++$this->cacheEpoch; + $context=$this->contextVersion; + $available=$this->available(); + $this->ready=$this->cacheTrusted=$available && !$this->stopped && $this->refresh===null + && $epoch===$this->cacheEpoch && $context===$this->contextVersion; + if (!$this->ready) { $this->status('initial-cache'); } + $this->signal(); + } + + public function configurationChanged(): void + { + if (!$this->enabled || $this->stopped) { return; } + $wasReady=$this->isReady(); + $this->ready=false; $this->configurationValid=false; + $epoch=++$this->cacheEpoch; $context=++$this->contextVersion; + $this->cancelDebounce(); + // Failure leaves readiness false and propagates to the explicit configuration owner. + ($this->invalidateConfiguration)(); + // A nested configuration update/stop must not be overwritten by this older callback. + if ($context===$this->contextVersion && !$this->stopped) { $this->configurationValid=true; } + $available=$wasReady && $this->cacheTrusted && $this->refresh===null && $this->available(); + $this->ready=$available && !$this->stopped && $this->refresh===null + && $epoch===$this->cacheEpoch && $context===$this->contextVersion; + $this->signal(); + } + + public function stop(): void + { + if (!$this->enabled || $this->stopped) { return; } + $this->stopped=true; $this->pendingReconnect=false; $this->ready=false; $this->cacheTrusted=false; ++$this->cacheEpoch; ++$this->contextVersion; + $this->cancelDebounce(); + if ($this->refresh!==null) { + $this->refresh->done=true; + $this->cancelTimer($this->refresh); + } + // No transport cancellation claim. In-flight results cannot restore readiness. + } + + private function reconnect(): void + { + if (!$this->enabled || $this->stopped) { return; } + $this->initialized=true; // A later init callback cannot bypass a reconnect barrier. + $this->ready=false; $this->cacheTrusted=false; ++$this->cacheEpoch; ++$this->contextVersion; + if ($this->refresh!==null) { + $this->pendingReconnect=true; + $this->cancelDebounce(); $this->signal(); return; // One explicit reconnect waits for drain. + } + $this->beginRefresh(); + } + + private function beginRefresh(): void + { + if ($this->stopped || $this->refresh!==null) { return; } + $state=(object)['done'=>false,'cancel'=>null,'outstanding'=>false,'context'=>$this->contextVersion]; + $this->refresh=$state; // Reserve before any injected callback can reenter. + $this->cancelDebounce(); + $this->signal(); // Immediately dirties already-active coordinator work while the gate is closed. + $fail=function (string $phase) use ($state): void { + if ($state->done) { return; } + $state->done=true; $this->ready=false; + $this->cancelTimer($state); + $this->status($phase); + if (!$state->outstanding) { $this->releaseRefresh($state); } + }; + try { + if ($state->done || $this->stopped) { return; } + $cancel=($this->schedule)($this->refreshTimeoutSeconds,fn()=>$fail('cache-refresh-deadline')); + if (!is_callable($cancel)) { throw new \RuntimeException('Timer cancellation required'); } + $state->cancel=\Closure::fromCallable($cancel); + if ($state->done || $this->stopped) { $this->cancelTimer($state); return; } + $state->outstanding=true; + $promise=($this->refreshCache)(); + if (!$promise instanceof PromiseInterface) { throw new \RuntimeException('Refresh promise required'); } + $promise->then(function (mixed $unused) use ($state,$fail): void { + $state->outstanding=false; + if ($state->done || $this->stopped || $state->context!==$this->contextVersion) { + $obsolete=!$state->done && !$this->stopped && $state->context!==$this->contextVersion; + $state->done=true; $this->cancelTimer($state); + if ($obsolete) { $this->status('cache-refresh-obsolete'); } + $this->releaseRefresh($state); + return; + } + $available=$this->available(); + if ($state->done || $this->stopped || $state->context!==$this->contextVersion) { + $obsolete=!$state->done && !$this->stopped && $state->context!==$this->contextVersion; + $state->done=true; $this->cancelTimer($state); + if ($obsolete) { $this->status('cache-refresh-obsolete'); } + $this->releaseRefresh($state); + return; + } + if (!$available) { $fail('refreshed-cache-unavailable'); return; } + $state->done=true; $this->cancelTimer($state); + if ($this->stopped || $state->context!==$this->contextVersion) { + if (!$this->stopped) { $this->status('cache-refresh-obsolete'); } + $this->releaseRefresh($state); + return; + } + $this->releaseRefresh($state); + $this->initialized=true; $this->ready=true; $this->cacheTrusted=true; ++$this->cacheEpoch; + $this->signal(); + },function (\Throwable $error) use ($state,$fail): void { + $state->outstanding=false; + if ($state->done) { $this->releaseRefresh($state); return; } + $fail('cache-refresh-failed'); + }); + } catch (\Throwable) { + $state->outstanding=false; + if ($state->done) { $this->releaseRefresh($state); } + $fail('cache-refresh-start'); + } + } + + private function releaseRefresh(object $state): void + { + if ($this->refresh!==$state) { return; } + $this->refresh=null; + if ($this->pendingReconnect && !$this->stopped) { + $this->pendingReconnect=false; + $this->beginRefresh(); // Services one actually received reconnect, never a failure retry. + } + } + + private function available(): bool + { + if (!$this->configurationValid) { return false; } + try { return ($this->cacheAvailable)()===true && $this->configurationValid; } catch (\Throwable) { return false; } + } + + private function signal(): void + { + if (!$this->enabled || $this->stopped) { return; } + if ($this->requestDepth>0 || $this->pendingCount>0) { $this->dispatch(); return; } + if (!$this->isReady() || $this->debounce!==null) { return; } + $slot=(object)['cancel'=>null,'armed'=>false]; $this->debounce=$slot; + try { + $cancel=($this->schedule)($this->debounceSeconds,function () use ($slot): void { + if ($this->debounce!==$slot) { return; } + $this->debounce=null; + // A scheduler must yield; synchronous callbacks must not spin dirty continuations. + if (!$slot->armed) { $this->status('debounce-timer'); return; } + if ($this->isReady()) { $this->dispatch(); } + }); + if (!is_callable($cancel)) { throw new \RuntimeException('Timer cancellation required'); } + $slot->cancel=\Closure::fromCallable($cancel); $slot->armed=true; + if ($this->debounce!==$slot) { $this->cancelTimer($slot); } + } catch (\Throwable) { + if ($this->debounce===$slot) { $this->debounce=null; } + $this->status('debounce-timer'); + } + } + + private function dispatch(): void + { + if ($this->stopped) { return; } + $this->continuationPending=false; // A new request consumes any older continuation intent. + ++$this->requestDepth; + try { + $promise=($this->request)(); + if (!$promise instanceof PromiseInterface) { throw new \RuntimeException('Coordinator promise required'); } + } catch (\Throwable) { $this->status('coordinator-request'); return; } + finally { --$this->requestDepth; $this->continueDirty(); } + if (isset($this->observed[$promise])) { return; } + $this->observed[$promise]=true; ++$this->pendingCount; + $promise->then(function (mixed $outcome): void { + --$this->pendingCount; + if (is_array($outcome)) { + // Consume only explicit unserved dirty work, never a guard refusal or failed write. + // Schedule before publishing: reentrant observers can coalesce or cancel this timer. + if (($outcome['continuation_required'] ?? null) === true + && ($outcome['state'] ?? null) === 'superseded' + && ($outcome['retry_required'] ?? null) === true + && ($outcome['integrity_uncertain'] ?? null) === false + && $this->pendingCount===0) { + $this->continuationPending=true; + $this->continueDirty(); + } + $this->publish($outcome); + } else { $this->status('coordinator-outcome'); } + },function (\Throwable $error): void { --$this->pendingCount; $this->status('coordinator-request'); }); + } + + private function continueDirty(): void + { + if (!$this->continuationPending || $this->pendingCount>0 || $this->requestDepth>0) { return; } + $this->continuationPending=false; + if ($this->isReady()) { $this->signal(); } + } + + private function cancelDebounce(): void + { + $slot=$this->debounce; $this->debounce=null; + if ($slot!==null) { $this->cancelTimer($slot); } + } + + private function cancelTimer(object $slot): void + { + $cancel=$slot->cancel; $slot->cancel=null; + if ($cancel!==null) { try { $cancel(); } catch (\Throwable) {} } + } + + private function status(string $phase): void { $this->publish(['state'=>'blocked','phase'=>$phase,'retry_required'=>true]); } + private function publish(array $outcome): void { try { ($this->report)($outcome); } catch (\Throwable) {} } +} diff --git a/src/Civ13/AdminFileSync.php b/src/Civ13/AdminFileSync.php new file mode 100644 index 00000000..06e04504 --- /dev/null +++ b/src/Civ13/AdminFileSync.php @@ -0,0 +1,228 @@ +civ13_guild_id,$app->role_ids); + return new self($app,$settings,$adminFile,$writer,$clock); + } + + /** Shared pure preflight: run after autoload but before credentials/client construction. */ + public static function validateConfiguration(array $settings, string $adminFile, string $guildId, array $roleIds): void + { + if (($settings['enabled'] ?? false) === false) { return; } + if (($settings['enabled'] ?? null) !== true + || !is_int($settings['max_bytes'] ?? null) || $settings['max_bytes'] < 1 || $settings['max_bytes'] > 16777216 + || !self::seconds($settings['debounce_seconds'] ?? null,5) + || !self::seconds($settings['refresh_timeout_seconds'] ?? null,300) + || !PortableProfile::absoluteAdminFile($adminFile)) { + throw new \InvalidArgumentException('Explicit bounded admin file synchronization settings required'); + } + if (preg_match('/^[1-9][0-9]{16,19}$/D',$guildId)!==1) { throw new \InvalidArgumentException('Explicit target guild required'); } + foreach (GameServer::ADMIN_PERMISSIONS as $name => $_) { + if (!is_string($roleIds[$name] ?? null) || preg_match('/^[1-9][0-9]{16,19}$/D',$roleIds[$name])!==1) { + throw new \InvalidArgumentException('Explicit required admin role mapping missing'); + } + } + if (count(array_unique(array_intersect_key($roleIds,GameServer::ADMIN_PERMISSIONS)))!==count(GameServer::ADMIN_PERMISSIONS)) { + throw new \InvalidArgumentException('Distinct required admin roles required'); + } + } + + private function __construct( + private readonly object $app, + array $settings, + string $adminFile, + ?callable $writer, + ?callable $clock + ) { + if (($app->verifier->readOnly ?? null) !== true) { throw new \LogicException('Admin file synchronization requires read-only verification'); } + $this->verifier=$app->verifier; + $this->discord=$app->discord; + $this->loop=$app->loop; + $this->guildId=$app->civ13_guild_id; + $this->roleIds=$app->role_ids; + $this->clock=\Closure::fromCallable($clock ?? time(...)); + $this->writer=\Closure::fromCallable($writer ?? new AdminFileWriter($adminFile,$settings['max_bytes'])); + $this->coordinator=new AdminSyncCoordinator(true, + function (): PromiseInterface { + if (!$this->configurationCurrent()) { throw new \RuntimeException('Admin file configuration changed before verification refresh'); } + if ($this->verificationProjectionPending) { + $this->verificationProjectionPending=false; + return resolve($this->captureVerification()); + } + $this->acquiringVerification=true; + try { + return $this->verifier->refreshHosted()->then(fn (mixed $unused): array => $this->captureVerification()) + ->finally(function (): void { $this->acquiringVerification=false; }); + } catch (\Throwable $error) { + $this->acquiringVerification=false; + throw $error; + } + }, + fn (int $generation): array => $this->members($generation), + function (array $snapshot,array $membership,int $generation): string { + if (!$this->configurationCurrent()) { throw new \RuntimeException('Admin file configuration changed'); } + $current=$this->verification(); + if ($current['records']!==$snapshot['records'] + || ($current['status']['last_success_utc'] ?? null)!==($snapshot['status']['last_success_utc'] ?? null)) { + throw new \RuntimeException('Verification identity snapshot changed'); + } + $now=($this->clock)(); + if (!$this->configurationCurrent()) { throw new \RuntimeException('Admin file configuration changed during validation'); } + if (!is_int($now)) { throw new \RuntimeException('Expected explicit Unix time'); } + return AdminSyncSnapshotGate::renderCached($current['status'],$snapshot['records'],$membership, + $this->guildId,$generation,$now,$this->roleIds,GameServer::ADMIN_PERMISSIONS,$this->trigger->state()); + }, + fn (string $bytes,int $generation,callable $guard): mixed => ($this->writer)($bytes,$generation,$guard)); + $this->trigger=new AdminCacheSyncTrigger(true,$this->guildId,fn ()=>$this->coordinator->request(), + fn (): bool => $this->configurationCurrent() && $this->guild()!==null, + function (): PromiseInterface { + if (!$this->configurationCurrent()) { throw new \RuntimeException('Admin file configuration changed before cache refresh'); } + $guild=$this->guild(); + if ($guild===null) { throw new \RuntimeException('Target guild cache unavailable'); } + $pending=[]; + foreach (['members','roles'] as $repository) { + try { + if (!$this->configurationCurrent()) { throw new \RuntimeException('Admin file configuration changed during cache refresh'); } + $promise=$guild->{$repository}->freshen(); + if (!$promise instanceof PromiseInterface) { throw new \RuntimeException('Cache refresh promise required'); } + $pending[]=$promise->then(static fn (mixed $result): bool => true,static fn (\Throwable $error): bool => false); + } catch (\Throwable) { $pending[]=resolve(false); } + } + // Do not release the trigger's refresh ownership while one repository still drains. + return all($pending)->then(static function (array $results): void { + if (in_array(false,$results,true)) { throw new \RuntimeException('Cache refresh failed'); } + }); + }, + function (float $delay,callable $callback): callable { + $timer=$this->loop->addTimer($delay,$callback); + return fn ()=>$this->loop->cancelTimer($timer); + }, + function (array $outcome): void { $this->app->logger->info('Admin file synchronization',$outcome); }, + static function (): void { throw new \LogicException('Stop and recreate composition for configuration changes'); }, + (float)$settings['debounce_seconds'],(float)$settings['refresh_timeout_seconds']); + try { + foreach ($this->trigger->listeners() as $event=>$listener) { + $this->listeners[$event]=$listener; + $this->discord->on($event,$listener); + } + // Existing Civ13 ready flag, not an inferred private Discord connection field. + if (($this->app->ready ?? false)===true) { $this->trigger->bootstrapReady(); } + } catch (\Throwable $error) { + try { $this->stop(); } catch (\Throwable) {} + throw $error; + } + } + + private readonly object $verifier; + private readonly object $discord; + private readonly object $loop; + private readonly string $guildId; + private readonly array $roleIds; + + /** The existing verifier already replaced its cache; do not fetch it again. */ + public function verificationChanged(): void + { + if (!$this->configurationCurrent() || $this->acquiringVerification) { return; } + $this->verificationProjectionPending=true; + $this->trigger->verificationChanged(); + } + + private function captureVerification(): array + { + $snapshot=$this->verification(); + $this->capturedRecords=$snapshot['records']; + return $snapshot; + } + + /** Call before releasing the owner or changing destination/role/source configuration. */ + public function stop(): void + { + if ($this->stopped) { return; } + $this->stopped=true; + $this->trigger->stop(); + $failure=null; + foreach ($this->listeners as $event=>$listener) { + try { $this->discord->removeListener($event,$listener); } catch (\Throwable $error) { $failure ??= $error; } + } + $this->listeners=[]; + if ($failure!==null) { throw $failure; } + } + + private function configurationCurrent(): bool + { + return !$this->stopped && $this->app->verifier===$this->verifier && $this->app->discord===$this->discord + && $this->app->loop===$this->loop && ($this->app->verifier->readOnly ?? null)===true + && $this->app->civ13_guild_id===$this->guildId && $this->app->role_ids===$this->roleIds; + } + + private function guild(): mixed { return $this->discord->guilds->get('id',$this->guildId); } + + /** Capture status and records together; count alone does not establish identity. */ + private function verification(): array + { + $before=$this->verifier->hostedStatus(); + $records=[]; + foreach ($this->verifier->verified as $record) { $records[]=$record; } + $after=$this->verifier->hostedStatus(); + if ($before!==$after || ($after['records'] ?? null)!==count($records)) { + throw new \RuntimeException('Verification snapshot changed during capture'); + } + $snapshot=['status'=>$after,'records'=>$records]; + return $snapshot; + } + + private function members(int $generation): array + { + if (!$this->configurationCurrent() || !$this->trigger->isReady() || ($guild=$this->guild())===null) { + throw new \RuntimeException('Target guild cache is not ready'); + } + $epoch=$this->trigger->epoch(); $roles=[]; $members=[]; + foreach (GameServer::ADMIN_PERMISSIONS as $name=>$_) { + $id=$this->roleIds[$name]; + if ($guild->roles->get('id',$id)!==null) { $roles[]=$id; } + } + // Same verified-record -> cached-member lookup used by the existing GameServer flow. + foreach ($this->capturedRecords as $record) { + if (!is_array($record) || !is_string($record['discord'] ?? null)) { throw new \RuntimeException('Invalid verified identity'); } + $member=$this->verifier->getVerifiedMember($record['discord']); + if ($member===null) { continue; } + $held=[]; + foreach ($roles as $role) { if ($member->roles->has($role)) { $held[]=$role; } } + $members[]=['id'=>$member->id,'roles'=>$held]; + } + return ['guild_id'=>$this->guildId,'generation'=>$generation,'cache_epoch'=>$epoch,'roles'=>$roles,'members'=>$members]; + } + + private static function seconds(mixed $value,int $maximum): bool + { + return (is_int($value) || is_float($value)) && is_finite((float)$value) && $value>0 && $value<=$maximum; + } +} diff --git a/src/Civ13/AdminListRenderer.php b/src/Civ13/AdminListRenderer.php new file mode 100644 index 00000000..6ac5a189 --- /dev/null +++ b/src/Civ13/AdminListRenderer.php @@ -0,0 +1,72 @@ +}>. + * Completeness/freshness must be established by the caller; use AdminSyncSnapshotGate. + */ + public static function render(array $verified, array $members, array $roleIds, array $permissions): string + { + if ($permissions === []) { throw new \InvalidArgumentException('Admin permissions must be explicit'); } + $seenRoles = []; + foreach ($permissions as $name => $permission) { + $role = $roleIds[$name] ?? null; + if (!is_string($name) || !self::snowflake($role) || isset($seenRoles[$role]) + || !is_array($permission) || !array_is_list($permission) || count($permission) !== 2 + || !self::field($permission[0]) || !is_string($permission[1]) + || preg_match('/^(0|[1-9][0-9]{0,4})$/D', $permission[1]) !== 1 || (int)$permission[1] > 65535) { + throw new \InvalidArgumentException('Invalid admin role configuration'); + } + $seenRoles[$role] = true; + } + if (!array_is_list($members)) { throw new \InvalidArgumentException('Expected member list'); } + $byId = []; + foreach ($members as $member) { + if (!is_array($member) || !self::snowflake($member['id'] ?? null) + || isset($byId[$member['id']]) || !is_array($member['roles'] ?? null) + || !array_is_list($member['roles'])) { + throw new \InvalidArgumentException('Invalid or ambiguous member snapshot'); + } + foreach ($member['roles'] as $role) { + if (!self::snowflake($role)) { throw new \InvalidArgumentException('Invalid member role'); } + } + $byId[$member['id']] = $member['roles']; + } + // Reuse existing identity normalization and ambiguity rejection, without fetching. + $verified = HostedVerificationSource::validate(json_encode($verified, JSON_THROW_ON_ERROR)); + $lines = []; + foreach ($verified as $record) { + $roles = $byId[$record['discord']] ?? null; + if ($roles === null) { continue; } + foreach ($permissions as $name => [$rank, $flags]) { + if (in_array($roleIds[$name], $roles, true)) { + // Legacy verification spelling may be valid but not a safe admin ckey. + // Reject selected unsafe authority; do not silently omit/revoke it. + if (preg_match('/^[a-z0-9]{1,256}$/D', $record['ss13']) !== 1) { + throw new \InvalidArgumentException('Selected admin ckey is not canonical'); + } + $lines[$record['ss13']] = $record['ss13'].';'.$rank.';'.$flags."|||\n"; + break; + } + } + } + ksort($lines, SORT_STRING); + return implode('', $lines); + } + + private static function snowflake(mixed $value): bool + { + return is_string($value) && preg_match('/^[0-9]{17,20}$/D', $value) === 1; + } + + private static function field(mixed $value): bool + { + return is_string($value) && $value !== '' && strlen($value) <= 256 + && preg_match('/[;|\x00-\x1F\x7F]/', $value) === 0; + } +} diff --git a/src/Civ13/AdminMemberSnapshotProvider.php b/src/Civ13/AdminMemberSnapshotProvider.php new file mode 100644 index 00000000..2b271b4c --- /dev/null +++ b/src/Civ13/AdminMemberSnapshotProvider.php @@ -0,0 +1,174 @@ +|PromiseInterface (normalized role IDs). + * fetchPage(guild,after,limit): list}>|PromiseInterface. + * observe(guild): {guild_id:string,connected:bool,epoch:int,generation:int}. + * clock(): monotonic finite seconds; deadline(delay,expire): callable cancellation. + * Deadline cancellation cancels only the injected timer, NEVER the transport. + */ + public function __construct( + callable $fetchRoles, + callable $fetchPage, + callable $observe, + callable $clock, + callable $deadline, + private readonly int $maxPages, + private readonly int $maxMembers, + private readonly float $timeoutSeconds + ) { + if ($maxPages < 1 || $maxPages > 1000 || $maxMembers < 0 || $maxMembers > 1000000 + || !is_finite($timeoutSeconds) || $timeoutSeconds <= 0 || $timeoutSeconds > 300) { + throw new \InvalidArgumentException('Invalid explicit snapshot limits'); + } + $this->fetchRoles = \Closure::fromCallable($fetchRoles); + $this->fetchPage = \Closure::fromCallable($fetchPage); + $this->observe = \Closure::fromCallable($observe); + $this->clock = \Closure::fromCallable($clock); + $this->deadline = \Closure::fromCallable($deadline); + } + + public function snapshot(string $guildId, int $generation): PromiseInterface + { + if ($this->busy) { return reject(new \RuntimeException('Membership provider is busy or draining')); } + if (!self::id($guildId) || $generation < 0) { return reject(new \InvalidArgumentException('Invalid snapshot identity')); } + $this->busy = true; + $state = (object)['done'=>false,'outstanding'=>false,'cancel'=>null,'started'=>null,'lastTime'=>null, + 'epoch'=>null,'roles'=>[],'members'=>[],'pages'=>0,'after'=>'0']; + $deferred = new Deferred(); $promise = $deferred->promise(); + $finish = function (?array $result, string $reason = '') use ($state, $deferred): void { + if ($state->done) { return; } + $state->done = true; + if ($state->cancel !== null) { try { ($state->cancel)(); } catch (\Throwable) {} } + // A timed-out request may still run in its transport. Do not overlap it. + if (!$state->outstanding) { $this->busy = false; } + $result === null ? $deferred->reject(new \RuntimeException($reason)) : $deferred->resolve($result); + }; + $check = function () use ($state, $guildId, $generation): float { + $now = ($this->clock)(); + if ((!is_int($now) && !is_float($now)) || !is_finite((float)$now) + || ($state->lastTime !== null && $now < $state->lastTime)) { + throw new \RuntimeException('Invalid monotonic snapshot clock'); + } + $state->lastTime = (float)$now; + if ($state->started !== null && $now - $state->started >= $this->timeoutSeconds) { + throw new \RuntimeException('Membership snapshot deadline exceeded'); + } + $life = ($this->observe)($guildId); + if (!is_array($life) || ($life['guild_id'] ?? null) !== $guildId + || ($life['connected'] ?? null) !== true || ($life['generation'] ?? null) !== $generation + || !is_int($life['epoch'] ?? null) || $life['epoch'] < 0 + || ($state->epoch !== null && $life['epoch'] !== $state->epoch)) { + throw new \RuntimeException('Membership lifecycle changed or unavailable'); + } + $state->epoch = $life['epoch']; + return (float)$now; + }; + // Every callback is converted to a promise; late results only release the busy gate. + $invoke = function (\Closure $operation, callable $accept) use ($state, $finish, $check): void { + if ($state->done) { return; } + try { $check(); } catch (\Throwable) { $finish(null,'Membership snapshot no longer current'); return; } + if ($state->done) { return; } + $state->outstanding = true; + try { $request = resolve($operation()); } + catch (\Throwable $error) { $request = reject($error); } + $request->then(function (mixed $value) use ($state, $finish, $check, $accept): void { + $state->outstanding = false; + if ($state->done) { $this->busy = false; return; } + try { $check(); if (!$state->done) { $accept($value); } } + catch (\Throwable) { $finish(null,'Invalid or obsolete membership response'); } + }, function (\Throwable $error) use ($state, $finish): void { + $state->outstanding = false; + if ($state->done) { $this->busy = false; return; } + $finish(null,'Membership request failed'); + }); + }; + $page = null; + $page = function () use (&$page, $invoke, $state, $finish, $check, $guildId, $generation): void { + if ($state->done) { return; } + if ($state->pages >= $this->maxPages) { $finish(null,'Membership page limit reached before terminal empty page'); return; } + ++$state->pages; + $invoke(fn () => ($this->fetchPage)($guildId,$state->after,1000), + function (mixed $rows) use (&$page, $invoke, $state, $finish, $check, $guildId, $generation): void { + if (!is_array($rows) || !array_is_list($rows) || count($rows) > 1000) { throw new \RuntimeException('Expected bounded member page'); } + if ($rows === []) { + $invoke(fn () => ($this->fetchRoles)($guildId), function (mixed $roles) use ($state, $finish, $check, $guildId, $generation): void { + if (self::roles($roles) !== $state->roles) { throw new \RuntimeException('Guild roles changed during pagination'); } + $completed = $check(); + $members = array_values($state->members); + usort($members, static fn (array $a, array $b): int => self::compare($a['id'],$b['id'])); + $finish(['guild_id'=>$guildId,'connected'=>true,'complete'=>true,'generation'=>$generation, + 'epoch'=>$state->epoch,'roles'=>$state->roles,'members'=>$members,'pages'=>$state->pages, + 'started_at'=>$state->started,'completed_at'=>$completed]); + }); + return; + } + if (count($state->members) + count($rows) > $this->maxMembers) { throw new \RuntimeException('Membership size limit exceeded'); } + $next = $state->after; + foreach ($rows as $row) { + if (!is_array($row) || !self::id($row['id'] ?? null) + || self::compare($row['id'],$state->after) <= 0 || isset($state->members['id:'.$row['id']])) { + throw new \RuntimeException('Invalid, duplicate or nonprogressing member'); + } + $roles = self::roles($row['roles'] ?? null); + if (array_diff($roles,$state->roles) !== []) { throw new \RuntimeException('Member refers to unknown guild role'); } + $state->members['id:'.$row['id']] = ['id'=>$row['id'],'roles'=>$roles]; + if (self::compare($row['id'],$next) > 0) { $next = $row['id']; } + } + $state->after = $next; + $page(); + }); + }; + try { + $state->started = $check(); + $cancel = ($this->deadline)($this->timeoutSeconds, fn () => $finish(null,'Membership snapshot deadline exceeded')); + if (!is_callable($cancel)) { throw new \RuntimeException('Deadline cancellation callback required'); } + $state->cancel = \Closure::fromCallable($cancel); + if ($state->done) { ($state->cancel)(); return $promise; } + $invoke(fn () => ($this->fetchRoles)($guildId), function (mixed $roles) use ($state, &$page): void { + $state->roles = self::roles($roles); + $page(); + }); + } catch (\Throwable) { $finish(null,'Membership snapshot initialization failed'); } + return $promise; + } + + private static function roles(mixed $roles): array + { + if (!is_array($roles) || !array_is_list($roles) || count($roles) > 1000) { throw new \RuntimeException('Invalid role snapshot'); } + $seen = []; + foreach ($roles as $role) { + if (!self::id($role) || isset($seen['id:'.$role])) { throw new \RuntimeException('Invalid or duplicate role'); } + $seen['id:'.$role] = true; + } + sort($roles,SORT_STRING); + return $roles; + } + + private static function id(mixed $value): bool + { + return is_string($value) && preg_match('/^[1-9][0-9]{16,19}$/D',$value) === 1; + } + + private static function compare(string $left, string $right): int + { + return (strlen($left) <=> strlen($right)) ?: strcmp($left,$right); + } +} diff --git a/src/Civ13/AdminSyncCoordinator.php b/src/Civ13/AdminSyncCoordinator.php new file mode 100644 index 00000000..800d4fb6 --- /dev/null +++ b/src/Civ13/AdminSyncCoordinator.php @@ -0,0 +1,141 @@ + 10) { throw new \InvalidArgumentException('Expected one to ten reconciliation passes'); } + $this->refresh = \Closure::fromCallable($refresh); + $this->members = \Closure::fromCallable($members); + $this->project = \Closure::fromCallable($project); + $this->write = \Closure::fromCallable($write); + } + + /** All requests during a burst share the final outcome; each invalidates older work. */ + public function request(): PromiseInterface + { + if (!$this->enabled) { + return resolve(['state'=>'disabled','generation'=>$this->generation,'passes'=>0,'writes'=>0, + 'last_written_generation'=>null,'retry_required'=>false,'integrity_uncertain'=>false,'continuation_required'=>false,'phase'=>'disabled']); + } + ++$this->generation; + if ($this->pending !== null) { $this->dirty = true; return $this->pending; } + $this->passes = 0; $this->writes = 0; $this->lastWrittenGeneration = null; + $this->deferred = new Deferred(); + $this->pending = $this->deferred->promise(); $promise = $this->pending; + $this->startPass(); + return $promise; + } + + private function startPass(): void + { + ++$this->passes; $this->dirty = false; + $generation = $this->generation; + $this->call($this->refresh)->then(function (mixed $snapshot) use ($generation): void { + if (!$this->current($generation)) { $this->completePass('superseded','refresh'); return; } + if (!is_array($snapshot) || !is_array($snapshot['records'] ?? null) + || !is_array($snapshot['status'] ?? null) + || ($snapshot['status']['available'] ?? null) !== true + || ($snapshot['status']['stale'] ?? null) !== false + || ($snapshot['status']['has_valid_list'] ?? null) !== true + || ($snapshot['status']['refresh_in_progress'] ?? null) !== false) { + $this->completePass('blocked','refresh'); return; + } + $this->call($this->members, $generation)->then(function (mixed $membership) use ($snapshot, $generation): void { + if (!$this->current($generation)) { $this->completePass('superseded','members'); return; } + try { + // The injected pure projector must validate complete, current and fresh inputs. + $bytes = ($this->project)($snapshot, $membership, $generation); + if (!is_string($bytes)) { throw new \UnexpectedValueException('Expected projected bytes'); } + } catch (\Throwable) { $this->completePass('blocked','projection'); return; } + if (!$this->current($generation)) { $this->completePass('superseded','projection'); return; } + $canCommit = function () use ($snapshot, $membership, $generation, $bytes): bool { + if (!$this->current($generation)) { return false; } + try { + // Re-run the pure gate with the adapter's current clock, not an old age. + $freshBytes = ($this->project)($snapshot, $membership, $generation); + } catch (\Throwable) { return false; } + return $this->current($generation) && is_string($freshBytes) && $freshBytes === $bytes; + }; + $this->call($this->write, $bytes, $generation, $canCommit)->then(function (mixed $result) use ($generation): void { + $state = is_array($result) ? ($result['state'] ?? null) : null; + if (!in_array($state, ['updated','unchanged','superseded','blocked'], true)) { + $this->finish('failed','write',true); return; + } + if ($state === 'updated') { ++$this->writes; $this->lastWrittenGeneration = $generation; } + $this->completePass($state,'write'); + }, function (\Throwable $error): void { + // A writer may have partially changed bytes. Do not silently retry it. + $this->finish('failed','write',true); + }); + }, function (\Throwable $error): void { $this->completePass('blocked','members'); }); + }, function (\Throwable $error): void { $this->completePass('blocked','refresh'); }); + } + + private function current(int $generation): bool + { + return $this->pending !== null && $this->generation === $generation && !$this->dirty; + } + + /** Convert synchronous adapter exceptions and asynchronous rejections to one path. */ + private function call(\Closure $adapter, mixed ...$arguments): PromiseInterface + { + try { return resolve($adapter(...$arguments)); } + catch (\Throwable $error) { return \React\Promise\reject($error); } + } + + private function completePass(string $state, string $phase): void + { + if ($this->dirty) { + if ($this->passes < $this->maxPasses) { $this->startPass(); return; } + // Only an already-received dirty request exhausted this burst's budget. + $this->finish('superseded',$phase,false,true); return; + } + $this->finish($state,$phase); + } + + private function finish(string $state, string $phase, bool $integrityUncertain = false, bool $continuationRequired = false): void + { + $deferred = $this->deferred; + $result = ['state'=>$state,'generation'=>$this->generation,'passes'=>$this->passes,'writes'=>$this->writes, + 'last_written_generation'=>$this->lastWrittenGeneration,'retry_required'=>$this->dirty || $state === 'superseded', + 'integrity_uncertain'=>$integrityUncertain,'continuation_required'=>$continuationRequired,'phase'=>$phase]; + // Clear before resolving: subscribers may synchronously request a new burst. + $this->pending = null; $this->deferred = null; $this->dirty = false; + $deferred?->resolve($result); + } +} diff --git a/src/Civ13/AdminSyncSnapshotGate.php b/src/Civ13/AdminSyncSnapshotGate.php new file mode 100644 index 00000000..9781c61c --- /dev/null +++ b/src/Civ13/AdminSyncSnapshotGate.php @@ -0,0 +1,112 @@ +, members:list}>}. + * Throws on unavailable/invalid input. Only a valid empty projection returns ''. + * $now is explicit Unix time; no clocks, I/O, listeners or configuration effects here. + */ + public static function render( + array $verificationStatus, + array $verified, + array $membership, + string $guildId, + int $currentGeneration, + int $now, + array $roleIds, + array $permissions + ): string { + self::assertVerification($verificationStatus, $verified, $now); + if (preg_match('/^[0-9]{17,20}$/D', $guildId) !== 1 + || ($membership['guild_id'] ?? null) !== $guildId + || ($membership['complete'] ?? null) !== true + || ($membership['connected'] ?? null) !== true + || $currentGeneration < 0 || ($membership['generation'] ?? null) !== $currentGeneration + || !is_array($membership['roles'] ?? null) || !array_is_list($membership['roles']) + || !is_array($membership['members'] ?? null)) { + throw new \RuntimeException('Admin sync requires a complete current guild snapshot'); + } + foreach ($membership['roles'] as $role) { + if (!is_string($role) || preg_match('/^[0-9]{17,20}$/D', $role) !== 1) { + throw new \RuntimeException('Invalid guild role snapshot'); + } + } + foreach ($permissions as $name => $_) { + if (!in_array($roleIds[$name] ?? null, $membership['roles'], true)) { + throw new \RuntimeException('Required admin role is absent from the guild'); + } + } + return AdminListRenderer::render($verified, $membership['members'], $roleIds, $permissions); + } + + /** + * Event-maintained cache path: no Gateway attestation and no membership TTL. + * $cacheState is freshly read from the application readiness barrier at EVERY precommit. + * $membership is a value projection of the existing target-guild cache, tagged when captured. + * A valid cache may yield no admins; not-ready/refresh-failed cache must never revoke them. + */ + public static function renderCached( + array $verificationStatus, + array $verified, + array $membership, + string $guildId, + int $currentGeneration, + int $now, + array $roleIds, + array $permissions, + array $cacheState + ): string { + self::assertVerification($verificationStatus, $verified, $now); + if (preg_match('/^[1-9][0-9]{16,19}$/D', $guildId) !== 1 + || ($membership['guild_id'] ?? null) !== $guildId + || ($cacheState['ready'] ?? null) !== true + || !is_int($cacheState['epoch'] ?? null) || $cacheState['epoch'] < 0 + || ($membership['cache_epoch'] ?? null) !== $cacheState['epoch'] + || $currentGeneration < 0 || ($membership['generation'] ?? null) !== $currentGeneration + || !is_array($membership['roles'] ?? null) || !array_is_list($membership['roles']) + || !is_array($membership['members'] ?? null)) { + throw new \RuntimeException('Admin sync requires an initialized current member cache'); + } + foreach ($membership['roles'] as $role) { + if (!is_string($role) || preg_match('/^[1-9][0-9]{16,19}$/D', $role) !== 1) { + throw new \RuntimeException('Invalid cached guild role'); + } + } + foreach ($permissions as $name => $_) { + if (!in_array($roleIds[$name] ?? null, $membership['roles'], true)) { + throw new \RuntimeException('Required admin role is absent from the guild cache'); + } + } + return AdminListRenderer::render($verified, $membership['members'], $roleIds, $permissions); + } + + private static function assertVerification(array $verificationStatus, array $verified, int $now): void + { + $success = $verificationStatus['last_success_utc'] ?? null; + $date = is_string($success) + ? \DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:s\Z', $success, new \DateTimeZone('UTC')) + : false; + $age = $verificationStatus['age_seconds'] ?? null; + if (($verificationStatus['available'] ?? null) !== true + || ($verificationStatus['stale'] ?? null) !== false + || ($verificationStatus['has_valid_list'] ?? null) !== true + || ($verificationStatus['refresh_in_progress'] ?? null) !== false + || ($verificationStatus['records'] ?? null) !== count($verified) + || !is_int($age) || $age < 0 || $age >= HostedVerificationSnapshot::FRESH_SECONDS + || $date === false || $date->format('Y-m-d\TH:i:s\Z') !== $success + || $now < $date->getTimestamp() || $now - $date->getTimestamp() >= HostedVerificationSnapshot::FRESH_SECONDS) { + throw new \RuntimeException('Admin sync requires a fresh successful verification snapshot'); + } + } + +} diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 61e940b1..d7acdd66 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -154,6 +154,7 @@ class Civ13 public Byond $byond; public Moderator $moderator; public Verifier $verifier; + private ?AdminFileSync $adminFileSync = null; public ?SS14Verifier $ss14verifier = null; public string $welcome_message = ''; @@ -440,8 +441,19 @@ private function afterConstruct( $this->byond = new Byond(); $this->httpServiceManager = new HttpServiceManager($this); $this->messageServiceManager = new MessageServiceManager($this); - $this->discord->on('init', function (Discord $discord) { + $adminSync = $options['admin_file_sync']; + $this->discord->on('init', function (Discord $discord) use ($adminSync) { $this->ready = true; + // Verifier's earlier init listener has assigned the read-only verifier by now. + // Uses the existing configured read-only verification feed; activation remains explicit. + if (($adminSync['enabled'] ?? false) !== false) { + try { $this->adminFileSync = AdminFileSync::attach($this, $adminSync, $adminSync['admin_file'] ?? ''); } + catch (\Throwable) { + $this->adminFileSync = null; + try { $this->logger->warning('Admin file synchronization inactive: runtime attachment failed'); } + catch (\Throwable) {} + } + } $this->logger->info("Logged in as {$this->discord->username} {$this->discord->user}"); $this->logger->info('------'); //$this->commandServiceManager = new CommandServiceManager($this->discord, $this->httpServiceManager, $this->messageServiceManager, $this); @@ -558,6 +570,7 @@ private function resolveOptions(array &$options = []): array 'technician_id' => '116927250145869826', 'verify_url' => 'http://valzargaming.com:8080/verified/', 'verification_read_only' => false, + 'admin_file_sync' => ['enabled' => false], 'web_address' => '', 'webapi' => null, 'webserver_url' => 'www.valzargaming.com', @@ -606,6 +619,7 @@ private function resolveOptions(array &$options = []): array ->setAllowedTypes('technician_id', 'string') ->setAllowedTypes('verify_url', 'string') ->setAllowedTypes('verification_read_only', 'bool') + ->setAllowedTypes('admin_file_sync', 'array') ->setAllowedTypes('web_address', 'string') ->setAllowedTypes('webapi', ['null', 'resource', HttpServer::class]) ->setAllowedTypes('webserver_url', 'string'); @@ -934,6 +948,7 @@ public function run(): void */ public function stop(bool $closeLoop = true): void { + $this->stopAdminFileSync(); $this->logger->info('Shutting down'); if (isset($this->httpServiceManager->socket)) { $this->httpServiceManager->socket->close(); @@ -948,6 +963,23 @@ public function stop(bool $closeLoop = true): void $this->loop->stop(); } } + /** Successful replacement from the current verifier only; disabled/unattached is inert. */ + public function hostedVerificationRefreshed(Verifier $verifier): void + { + if ($this->adminFileSync === null || !isset($this->verifier) || $this->verifier !== $verifier) { return; } + $this->adminFileSync->verificationChanged(); + } + + /** Cleanup failure must not prevent the existing bot shutdown or escape our destructor. */ + private function stopAdminFileSync(): void + { + try { $this->adminFileSync?->stop(); } + catch (\Throwable) { + try { if (isset($this->logger)) { $this->logger->warning('Admin file synchronization listener cleanup failed'); } } + catch (\Throwable) {} + } + } + /** * Restarts the application by first stopping it and then calling the OS restart function. * @@ -2276,6 +2308,7 @@ public function adminlistUpdate(?array $required_roles = null): PromiseInterface /** Cancels every timer the bot registered. */ public function __destruct() { + $this->stopAdminFileSync(); foreach ($this->timers as $timer) { $this->loop->cancelTimer($timer); } diff --git a/src/Civ13/PortableProfile.php b/src/Civ13/PortableProfile.php index 7ee8375a..7dfd1ddc 100644 --- a/src/Civ13/PortableProfile.php +++ b/src/Civ13/PortableProfile.php @@ -77,10 +77,49 @@ public static function runtimeOptions(array $profile): array $options['verify_url'] = $profile['verification_source']['url']; $options['verification_read_only'] = true; } + // Admin-file authority is opt-in here, never inherited through ambient options. + $options['admin_file_sync'] = self::adminFileSyncOptions($profile, $options); $options['webserver_url'] = '127.0.0.1'; return $options; } + /** Pure configuration mapping; never opens the admin destination. */ + private static function adminFileSyncOptions(array $profile, array $options): array + { + $settings = array_key_exists('admin_file_sync', $profile) ? $profile['admin_file_sync'] : ['enabled' => false]; + if (!is_array($settings) || !is_bool($settings['enabled'] ?? null)) { + throw new \InvalidArgumentException('Explicit admin file sync enabled flag required'); + } + $keys = array_keys($settings); sort($keys); + if (!$settings['enabled']) { + if ($keys !== ['enabled']) { throw new \InvalidArgumentException('Disabled admin file sync accepts only enabled=false'); } + return ['enabled' => false]; + } + if ($keys !== ['debounce_seconds', 'enabled', 'max_bytes', 'refresh_timeout_seconds'] + || !is_int($settings['max_bytes']) || $settings['max_bytes'] < 1 || $settings['max_bytes'] > 16777216) { + throw new \InvalidArgumentException('Explicit bounded admin file sync configuration required'); + } + foreach (['debounce_seconds' => 5, 'refresh_timeout_seconds' => 300] as $field => $maximum) { + $value = $settings[$field]; + if ((!is_int($value) && !is_float($value)) || !is_finite((float)$value) || $value <= 0 || $value > $maximum) { + throw new \InvalidArgumentException('Invalid admin file sync timing limit'); + } + $settings[$field] = (float)$value; + } + if (($options['verification_read_only'] ?? null) !== true + || ($options['civ13_guild_id'] ?? '') === '' + || !is_array($options['role_ids'] ?? null) || $options['role_ids'] === [] + || !is_array($profile['servers'] ?? null) || count($profile['servers']) !== 1) { + throw new \InvalidArgumentException('Admin file sync requires explicit read-only verifier, guild, roles and one server'); + } + $server = reset($profile['servers']); + if (!is_array($server) || !self::absoluteAdminFile($server['admin_file'] ?? null)) { + throw new \InvalidArgumentException('Admin file sync requires the existing explicit admin_file destination'); + } + $settings['admin_file'] = $server['admin_file']; + return $settings; + } + /** An inherited process environment is not an explicit credential source. */ public static function environmentValue(string $name, array $loadedNames): string { diff --git a/src/Civ13/Server/AdminFileWriter.php b/src/Civ13/Server/AdminFileWriter.php new file mode 100644 index 00000000..4a0117b7 --- /dev/null +++ b/src/Civ13/Server/AdminFileWriter.php @@ -0,0 +1,124 @@ + 16777216) { + throw new \InvalidArgumentException('Explicit local admin file and bounded byte budget required'); + } + $normalized = self::normalize($path); + $tail = PHP_OS_FAMILY === 'Windows' ? substr($normalized,3) : substr($normalized,1); + if ($tail === '' || str_starts_with($normalized,'//') + || (PHP_OS_FAMILY === 'Windows' && str_contains($tail,':')) + || array_intersect(explode('/',$tail),['','.','..']) !== []) { + throw new \InvalidArgumentException('Canonical local file path required'); + } + } + + /** + * Returns updated, checked unchanged, superseded (guard), or blocked (no mutation). + * Throws after possible mutation; callers must treat file integrity as uncertain. + * Guard must be pure and synchronous. No asynchronous yield between guard and write. + */ + final public function __invoke(string $bytes, int $generation, callable $canCommit): array + { + if ($generation < 0 || strlen($bytes) > $this->maxBytes) { return $this->blocked('invalid-budget-or-generation'); } + if (!$this->allowed($canCommit)) { return ['state'=>'superseded']; } + $before = $this->pathStat(); + if ($before === null) { return $this->blocked('path-unavailable-or-unsafe'); } + // r+b never creates a missing path and never truncates merely by opening it. + $file = @fopen($this->path,'r+b'); + if ($file === false) { return $this->blocked('open-failed'); } + $locked = false; + try { + $wouldBlock = 0; + if (!@flock($file,LOCK_EX | LOCK_NB,$wouldBlock)) { + return $this->blocked($wouldBlock === 1 ? 'lock-busy' : 'lock-failed'); + } + $locked = true; + $opened = @fstat($file); $current = $this->pathStat(); + if (!$this->same($before,$opened) || !$this->same($opened,$current) + || !is_int($opened['size'] ?? null) || $opened['size'] < 0 || $opened['size'] > $this->maxBytes) { + return $this->blocked('identity-or-size-unavailable'); + } + if (!$this->allowed($canCommit)) { return ['state'=>'superseded']; } + if (!@rewind($file)) { return $this->blocked('read-position-failed'); } + $existing = @stream_get_contents($file,$this->maxBytes + 1); + if ($existing === false || strlen($existing) > $this->maxBytes || strlen($existing) !== $opened['size']) { + return $this->blocked('bounded-read-failed'); + } + if (!$this->same($opened,$this->pathStat())) { return $this->blocked('path-changed'); } + if (!@rewind($file)) { return $this->blocked('write-position-failed'); } + // Includes the unchanged acknowledgement: stale data must not be called current. + if (!$this->allowed($canCommit)) { return ['state'=>'superseded']; } + if ($existing === $bytes) { return ['state'=>'unchanged']; } + $length = strlen($bytes); + for ($offset = 0; $offset < $length; $offset += $written) { + $written = $this->writeChunk($file,substr($bytes,$offset,65536)); + if ($written === false || $written <= 0 || $written > min(65536,$length - $offset)) { + throw new \RuntimeException('Admin file write failed; integrity uncertain'); + } + } + if (!$this->truncate($file,$length) || !$this->flush($file)) { + throw new \RuntimeException('Admin file completion failed; integrity uncertain'); + } + if (!$this->same($opened,$this->pathStat())) { + throw new \RuntimeException('Admin file path changed after mutation; integrity uncertain'); + } + return ['state'=>'updated']; + } finally { + if ($locked) { @flock($file,LOCK_UN); } + @fclose($file); + } + } + + private function blocked(string $reason): array { return ['state'=>'blocked','reason'=>$reason]; } + + private function allowed(callable $guard): bool + { + try { return $guard() === true; } catch (\Throwable) { return false; } + } + + /** Conservative portable checks, not an atomic no-follow/open-by-handle boundary. */ + private function pathStat(): ?array + { + clearstatcache(true); + $stat = @lstat($this->path); $resolved = @realpath($this->path); + if (!is_array($stat) || !self::regular($stat) || $resolved === false + || self::normalize($resolved) !== self::normalize($this->path)) { return null; } + return $stat; + } + + private function same(mixed $left, mixed $right): bool + { + // Modern PHP supplies Windows file IDs too. Missing/zero IDs fail closed; + // never replace this with size/mtime equality to make a platform test pass. + return PHP_INT_SIZE >= 8 && is_array($left) && is_array($right) + && self::regular($left) && self::regular($right) + && is_int($left['dev'] ?? null) && is_int($right['dev'] ?? null) + && is_int($left['ino'] ?? null) && is_int($right['ino'] ?? null) + && $left['ino'] !== 0 && $right['ino'] !== 0 + && $left['dev'] === $right['dev'] && $left['ino'] === $right['ino']; + } + + private static function regular(array $stat): bool + { + return is_int($stat['mode'] ?? null) && ($stat['mode'] & 0170000) === 0100000; + } + + private static function normalize(string $path): string + { + return PHP_OS_FAMILY === 'Windows' ? strtolower(str_replace('\\','/',$path)) : $path; + } + + // Narrow failure-injection seam for temporary-file tests; production calls PHP directly. + protected function writeChunk($file, string $bytes): int|false { return @fwrite($file,$bytes); } + protected function truncate($file, int $length): bool { return @ftruncate($file,$length); } + protected function flush($file): bool { return @fflush($file); } +} diff --git a/src/Civ13/Verifier.php b/src/Civ13/Verifier.php index 005d469c..8f088c60 100644 --- a/src/Civ13/Verifier.php +++ b/src/Civ13/Verifier.php @@ -1221,6 +1221,15 @@ public function refreshHosted(): PromiseInterface return $this->hosted->refresh(fn () => HostedVerificationSource::fetch($this->civ13->browser))->then(function (array $status): array { $this->verified = new Collection($this->hosted->records(), 'discord'); $this->civ13->verifier_online = $status['available']; + // Existing successful refresh only. Coalesced callers must not duplicate the notification. + if ($status['available'] === true && ($status['coalesced'] ?? true) === false) { + try { $this->civ13->hostedVerificationRefreshed($this); } + catch (\Throwable) { + // Optional synchronization must not invalidate a successful core refresh. + try { $this->logger->warning('Admin file synchronization notification failed'); } + catch (\Throwable) {} + } + } $this->logger->info('Hosted verification refresh', $status); return $status; }); diff --git a/tests/local/admin-cache-sync.php b/tests/local/admin-cache-sync.php new file mode 100644 index 00000000..b40db1ab --- /dev/null +++ b/tests/local/admin-cache-sync.php @@ -0,0 +1,290 @@ +true,'availableHook'=>null,'refreshHook'=>null,'memberHook'=>null,'writeHook'=>null, + 'requestHook'=>null,'scheduleHook'=>null,'reportHook'=>null,'configHook'=>null,'cancelHook'=>null,'now'=>1800000000, + 'refreshes'=>0,'requests'=>0,'memberReads'=>0,'timerCalls'=>0,'timerCancels'=>0,'configChanges'=>0, + 'timers'=>[],'reports'=>[],'file'=>'retained existing admin file','writes'=>[], + 'guild'=>'770000000000000001','role'=>'880000000000000001','user'=>'990000000000000001']; + $f->roles=[$f->role]; $f->members=[['id'=>$f->user,'roles'=>[$f->role]]]; + $trigger=null; + $coordinator=new AdminSyncCoordinator(true, + static function () use ($f): array { + return ['status'=>['available'=>true,'stale'=>false,'has_valid_list'=>true,'refresh_in_progress'=>false, + 'records'=>1,'age_seconds'=>0,'last_success_utc'=>gmdate('Y-m-d\TH:i:s\Z',$f->now)], + 'records'=>[['discord'=>$f->user,'ss13'=>'fakeadmin']]]; + }, + static function (int $generation) use ($f,&$trigger): mixed { + ++$f->memberReads; + if ($f->memberHook!==null) { return ($f->memberHook)($generation); } + if (!$f->available) { throw new RuntimeException('Guild/cache missing'); } + return ['guild_id'=>$f->guild,'generation'=>$generation,'cache_epoch'=>$trigger->epoch(),'roles'=>$f->roles,'members'=>$f->members]; + }, + static function (array $verification,array $membership,int $generation) use ($f,&$trigger): string { + return AdminSyncSnapshotGate::renderCached($verification['status'],$verification['records'],$membership, + $f->guild,$generation,$f->now,['Admin'=>$f->role],['Admin'=>['Admin','16254']],$trigger->state()); + }, + static function (string $bytes,int $generation,callable $guard) use ($f): mixed { + if ($f->writeHook!==null) { return ($f->writeHook)($bytes,$generation,$guard); } + if (!$guard()) { return ['state'=>'superseded']; } + $f->file=$bytes; $f->writes[]=$bytes; return ['state'=>'updated']; + },$maxPasses); + $trigger=new AdminCacheSyncTrigger($enabled,$f->guild, + static function () use ($f,$coordinator): mixed { ++$f->requests; return $f->requestHook!==null?($f->requestHook)($coordinator):$coordinator->request(); }, + static function () use ($f): mixed { return $f->availableHook!==null?($f->availableHook)():$f->available; }, + static function () use ($f): mixed { ++$f->refreshes; return $f->refreshHook!==null?($f->refreshHook)():resolve(null); }, + static function (float $delay,callable $callback) use ($f): mixed { + ++$f->timerCalls; + if ($f->scheduleHook!==null) { return ($f->scheduleHook)($delay,$callback); } + $slot=(object)['delay'=>$delay,'callback'=>$callback,'active'=>true]; $f->timers[]=$slot; + return static function () use ($f,$slot): void { if ($slot->active) { $slot->active=false; ++$f->timerCancels; if ($f->cancelHook!==null) { ($f->cancelHook)(); } } }; + }, + static function (array $outcome) use ($f): void { $f->reports[]=$outcome; if ($f->reportHook!==null) { ($f->reportHook)(); } }, + static function () use ($f): void { ++$f->configChanges; if ($f->configHook!==null) { ($f->configHook)(); } },0.25,5.0); + return [$trigger,$f]; +} +function cacheTick(object $f,float $delay=0.25): void { + foreach ($f->timers as $slot) { if ($slot->active && $slot->delay===$delay) { $slot->active=false; ($slot->callback)(); return; } } + throw new RuntimeException('Expected synthetic timer'); +} +function cacheEvent(AdminCacheSyncTrigger $t,object $f,string $name='GUILD_MEMBER_UPDATE'): void { ($t->listeners()[$name])((object)['guild_id'=>$f->guild],null,null); } +$admin="fakeadmin;Admin;16254|||\n"; +[$t,$f]=cacheFixture(); cacheEvent($t,$f); +cacheCheck(!$t->isReady() && $f->requests===0 && $f->refreshes===0 && $f->writes===[],'Before-init event obtained authority'); +($t->listeners()['init'])(); cacheTick($f); +cacheCheck($t->isReady() && $f->file===$admin && $f->refreshes===0,'Init did not trust initialized cache directly'); +cacheCheck($t->listeners()===$t->listeners(),'Listener closure identities changed'); +($t->listeners()['init'])(); cacheCheck($f->requests===1,'Duplicate init restarted work'); +// The library has already mutated its cache before these event callbacks run. +$f->members[0]['roles']=[]; cacheEvent($t,$f); cacheEvent($t,$f); cacheTick($f); +cacheCheck($f->file==='' && $f->refreshes===0 && $f->requests===2,'Cached role removal not authoritative or caused HTTP refresh'); +$f->members=[['id'=>$f->user,'roles'=>[$f->role]]]; cacheEvent($t,$f,'GUILD_MEMBER_ADD'); cacheTick($f); +cacheCheck($f->file===$admin,'Cached member addition not projected'); +$f->members=[]; cacheEvent($t,$f,'GUILD_MEMBER_REMOVE'); cacheTick($f); +cacheCheck($f->file==='' && $f->refreshes===0,'Cached admin departure not authoritative'); +// No artificial membership TTL: later events use current cache, with fresh verifier status. +$f->now+=86400; $f->members=[['id'=>$f->user,'roles'=>[$f->role]]]; cacheEvent($t,$f); cacheTick($f); +cacheCheck($f->file===$admin && $t->isReady(),'Cache was made stale by elapsed wall time'); +$f->roles=[]; cacheEvent($t,$f,'GUILD_ROLE_DELETE'); cacheTick($f); +cacheCheck($f->file===$admin && end($f->reports)['state']==='blocked','Missing configured role cleared file'); +[$t,$f]=cacheFixture(); $f->available=false; ($t->listeners()['init'])(); +cacheCheck(!$t->isReady() && $f->requests===0 && $f->file==='retained existing admin file','Missing initial guild/cache cleared file'); +[$t,$f]=cacheFixture(); $t->bootstrapReady(); cacheTick($f); +cacheCheck($f->file===$admin && $f->refreshes===0,'Explicit known-ready bootstrap did not use cache'); +// Reconnect exclusively refreshes caches; events during refresh do not refresh again. +$pending=new Deferred(); $f->refreshHook=static fn()=>$pending->promise(); ($t->listeners()['reconnected'])(); +cacheCheck(!$t->isReady() && $f->refreshes===1 && $f->file===$admin,'Reconnect barrier not closed'); +$f->members=[]; cacheEvent($t,$f); cacheEvent($t,$f,'GUILD_ROLE_UPDATE'); +cacheCheck($f->refreshes===1 && $f->requests===1 && !$t->isReady(),'Refresh-time events started HTTP/reconciliation prematurely'); +$pending->resolve(null); cacheTick($f); +cacheCheck($t->isReady() && $f->file==='' && $f->refreshes===1,'Refresh completion did not project latest cache'); +// Timeout is a result barrier, not cancellation; never-settling requests remain closed/draining. +[$t,$f]=cacheFixture(); $t->bootstrapReady(); cacheTick($f); $pending=new Deferred(); $f->refreshHook=static fn()=>$pending->promise(); +($t->listeners()['reconnected'])(); cacheTick($f,5.0); cacheEvent($t,$f); $f->members=[]; +$t->configurationChanged(); +cacheCheck(!$t->isReady() && $f->refreshes===1 && $f->file===$admin && $f->requests===1,'Timeout retried or retained partial cache authority'); +$pending->resolve(null); +cacheCheck(!$t->isReady() && $f->file===$admin,'Late refresh restored authority after timeout'); +$f->refreshHook=null; ($t->listeners()['reconnected'])(); cacheTick($f); +cacheCheck($t->isReady() && $f->refreshes===2 && $f->file==='','Later explicit reconnect failed to recover'); +foreach (['reject','throw','nonpromise','unavailable'] as $mode) { + [$t,$f]=cacheFixture(); $t->bootstrapReady(); cacheTick($f); + $f->refreshHook=static function () use ($mode,$f): mixed { + if ($mode==='throw') { throw new RuntimeException('synthetic refresh failure'); } + if ($mode==='unavailable') { $f->available=false; return resolve(null); } + return $mode==='nonpromise'?null:reject(new RuntimeException('synthetic refresh failure')); + }; + ($t->listeners()['reconnected'])(); cacheEvent($t,$f); $t->configurationChanged(); + cacheCheck(!$t->isReady() && $f->file===$admin && $f->refreshes===1,'Failed refresh cleared or reauthorized partial cache'); +} +// Cache epoch invalidates delayed writer immediately, even before debounce or another read. +[$t,$f]=cacheFixture(); $write=new Deferred(); $guard=null; +$f->writeHook=static function (string $bytes,int $generation,callable $canCommit) use ($f,$write,&$guard): mixed { + if ($guard===null) { $guard=$canCommit; return $write->promise(); } + if (!$canCommit()) { return ['state'=>'superseded']; } + $f->file=$bytes; $f->writes[]=$bytes; return ['state'=>'updated']; +}; +$t->bootstrapReady(); cacheTick($f); $f->members=[]; cacheEvent($t,$f); +cacheCheck(!$guard() && $f->requests===2 && $f->refreshes===0,'Event did not immediately invalidate entered cache writer'); +$write->resolve(['state'=>'superseded']); +cacheCheck($f->file==='' && count($f->reports)===1,'Dirty event lost current cached projection'); +// Stop suppresses queued callbacks and late refresh success. +[$t,$f]=cacheFixture(); $t->bootstrapReady(); $late=$f->timers[0]->callback; $epoch=$t->epoch(); $t->stop(); $late(); cacheEvent($t,$f); +cacheCheck(!$t->isReady() && $t->epoch()>$epoch && $f->requests===0 && $f->timerCancels===1,'Stop failed to fence queued callback'); +[$t,$f]=cacheFixture(); $pending=new Deferred(); $f->refreshHook=static fn()=>$pending->promise(); ($t->listeners()['reconnected'])(); $t->stop(); $pending->resolve(null); +cacheCheck(!$t->isReady() && $f->requests===0 && $f->writes===[],'Stopped refresh restored authority'); +[$t,$f]=cacheFixture(); $t->bootstrapReady(); $epoch=$t->epoch(); $t->configurationChanged(); cacheTick($f); +cacheCheck($f->configChanges===1 && $t->epoch()>$epoch && $f->requests===1,'Configuration did not invalidate/coalesce current cache'); +[$t,$f]=cacheFixture(false); $t->bootstrapReady(); $t->configurationChanged(); $t->stop(); +foreach ($t->listeners() as $listener) { $listener((object)['guild_id'=>$f->guild]); } +cacheCheck(!$t->isReady() && $f->requests===0 && $f->refreshes===0 && $f->timerCalls===0 && $f->configChanges===0,'Disabled cache trigger had effects'); +[$t,$f]=cacheFixture(); ($t->listeners()['GUILD_MEMBER_UPDATE'])((object)['guild_id'=>'770000000000000002']); +cacheCheck($t->epoch()===0 && $f->timerCalls===0,'Other guild invalidated target cache'); +// An expiry during cache-availability checking cannot restore readiness afterward. +[$t,$f]=cacheFixture(); $pending=new Deferred(); $f->refreshHook=static fn()=>$pending->promise(); ($t->listeners()['reconnected'])(); +$f->availableHook=static function () use ($f): bool { cacheTick($f,5.0); return true; }; $pending->resolve(null); +cacheCheck(!$t->isReady() && $f->requests===0,'Reentrant refresh expiry restored readiness'); +foreach ([static fn()=>null,static function (): mixed { throw new RuntimeException('timer failed'); }] as $hook) { + [$t,$f]=cacheFixture(); $f->scheduleHook=$hook; ($t->listeners()['reconnected'])(); + cacheCheck(!$t->isReady() && $f->refreshes===0 && $f->writes===[],'Broken deadline scheduler started refresh'); +} +[$t,$f]=cacheFixture(); $f->scheduleHook=static function (float $delay,callable $callback): callable { $callback(); return static function (): void {}; }; +($t->listeners()['reconnected'])(); cacheCheck(!$t->isReady() && $f->refreshes===0,'Synchronous deadline started expired refresh'); +// Late init cannot bless an outstanding or failed reconnect cache. +[$t,$f]=cacheFixture(); $pending=new Deferred(); $f->refreshHook=static fn()=>$pending->promise(); +($t->listeners()['reconnected'])(); ($t->listeners()['init'])(); $t->bootstrapReady(); +cacheCheck(!$t->isReady() && $f->requests===0,'Late init bypassed reconnect barrier'); +$pending->reject(new RuntimeException('synthetic')); ($t->listeners()['init'])(); $t->configurationChanged(); +cacheCheck(!$t->isReady() && $f->requests===0,'Late init/config blessed failed cache'); +// Reconnect bursts coalesce ONE pending refresh after the obsolete attempt drains. +[$t,$f]=cacheFixture(); $first=new Deferred(); $second=new Deferred(); +$f->refreshHook=static function () use ($f,$first,$second): PromiseInterface { return $f->refreshes===1?$first->promise():$second->promise(); }; +($t->listeners()['reconnected'])(); ($t->listeners()['reconnected'])(); ($t->listeners()['reconnected'])(); +cacheCheck($f->refreshes===1 && !$t->isReady(),'Reconnect burst overlapped refreshes'); +$first->resolve(null); +cacheCheck(!$t->isReady() && $f->refreshes===2 && $f->requests===0 + && end($f->reports)['phase']==='cache-refresh-obsolete','Obsolete reconnect lost pending refresh or restored authority'); +$second->resolve(null); cacheTick($f); +cacheCheck($t->isReady() && $f->refreshes===2 && $f->file===$admin,'Coalesced reconnect did not restore trusted cache'); +// A received reconnect waits through timeout/drain; deadline alone never loops. +[$t,$f]=cacheFixture(); $first=new Deferred(); $second=new Deferred(); +$f->refreshHook=static function () use ($f,$first,$second): PromiseInterface { return $f->refreshes===1?$first->promise():$second->promise(); }; +($t->listeners()['reconnected'])(); cacheTick($f,5.0); ($t->listeners()['reconnected'])(); +cacheCheck($f->refreshes===1 && !$t->isReady(),'Timed-out outstanding refresh overlapped'); +$first->resolve(null); cacheCheck($f->refreshes===2 && !$t->isReady(),'Pending reconnect not serviced after timeout drain'); +$second->resolve(null); cacheTick($f); cacheCheck($t->isReady(),'Pending reconnect completion did not restore cache'); +// Configuration failure during a refresh must fence that attempt permanently. +[$t,$f]=cacheFixture(); $pending=new Deferred(); $f->refreshHook=static fn()=>$pending->promise(); +($t->listeners()['reconnected'])(); $f->configHook=static function (): void { throw new RuntimeException('synthetic configuration failure'); }; +try { $t->configurationChanged(); throw new LogicException('Configuration failure swallowed'); } catch (RuntimeException $expected) {} +$pending->resolve(null); +cacheCheck(!$t->isReady() && $f->requests===0,'Failed configuration was overwritten by refresh completion'); +// A reconnect queued before failed config cannot bless that failed config after drain. +[$t,$f]=cacheFixture(); $first=new Deferred(); $second=new Deferred(); +$f->refreshHook=static function () use ($f,$first,$second): PromiseInterface { return $f->refreshes===1?$first->promise():$second->promise(); }; +($t->listeners()['reconnected'])(); ($t->listeners()['reconnected'])(); +$f->configHook=static function (): void { throw new RuntimeException('synthetic queued configuration failure'); }; +try { $t->configurationChanged(); throw new LogicException('Configuration failure swallowed'); } catch (RuntimeException $expected) {} +$first->resolve(null); $second->resolve(null); +cacheCheck(!$t->isReady() && $f->refreshes===2 && $f->requests===0 && $f->file==='retained existing admin file', + 'Queued refresh blessed failed configuration'); +$f->configHook=null; $t->configurationChanged(); $f->refreshHook=null; ($t->listeners()['reconnected'])(); cacheTick($f); +cacheCheck($t->isReady() && $f->file===$admin,'Successful explicit config update and reconnect could not recover'); +// An older successful callback cannot bless a nested failed configuration update. +[$t,$f]=cacheFixture(); $t->bootstrapReady(); cacheTick($f); +$f->configHook=static function () use ($t,$f): void { + $f->configHook=static function (): void { throw new RuntimeException('nested failed config'); }; + try { $t->configurationChanged(); } catch (RuntimeException $expected) {} +}; +$t->configurationChanged(); +cacheCheck(!$t->isReady() && $f->configChanges===2 && $f->file===$admin,'Older callback blessed nested invalid configuration'); +// Deadline during refresh construction followed by a throw releases only drained ownership. +[$t,$f]=cacheFixture(); $f->refreshHook=static function () use ($f): mixed { cacheTick($f,5.0); throw new RuntimeException('synthetic after deadline'); }; +($t->listeners()['reconnected'])(); cacheCheck(!$t->isReady() && $f->refreshes===1,'Expired throwing refresh obtained authority'); +$f->refreshHook=null; ($t->listeners()['reconnected'])(); cacheTick($f); +cacheCheck($t->isReady() && $f->refreshes===2,'Expired throwing refresh kept a false outstanding gate'); +// Timer cancellation and availability callbacks cannot reopen stopped/obsolete context. +[$t,$f]=cacheFixture(); $pending=new Deferred(); $f->refreshHook=static fn()=>$pending->promise(); ($t->listeners()['reconnected'])(); +$f->cancelHook=static function () use ($t,$f): void { $f->cancelHook=null; $t->stop(); }; $pending->resolve(null); +cacheCheck(!$t->isReady() && $f->requests===0,'Timer-cancel reentrancy reopened stopped readiness'); +[$t,$f]=cacheFixture(); $first=new Deferred(); $second=new Deferred(); +$f->refreshHook=static function () use ($f,$first,$second): PromiseInterface { return $f->refreshes===1?$first->promise():$second->promise(); }; +($t->listeners()['reconnected'])(); +$f->availableHook=static function () use ($t,$f): bool { $f->availableHook=null; ($t->listeners()['reconnected'])(); return true; }; $first->resolve(null); +cacheCheck(!$t->isReady() && $f->refreshes===2 && $f->requests===0,'Availability reentrancy blessed obsolete reconnect'); +$second->resolve(null); cacheTick($f); cacheCheck($t->isReady(),'Availability-reentrant pending reconnect lost'); +// Pass-budget exhaustion preserves a received event without waiting for another gateway event. +foreach (['remove','add'] as $change) { + [$t,$f]=cacheFixture(true,1); + if ($change==='add') { $f->members=[]; } + $t->bootstrapReady(); cacheTick($f); $before=$f->file; + $pending=new Deferred(); $f->memberHook=static fn()=>$pending->promise(); + cacheEvent($t,$f); cacheTick($f); + $f->members=$change==='remove'?[]:[['id'=>$f->user,'roles'=>[$f->role]]]; + cacheEvent($t,$f,$change==='remove'?'GUILD_MEMBER_REMOVE':'GUILD_MEMBER_UPDATE'); + cacheEvent($t,$f); // Repeated dirty notifications still produce one continuation timer. + $f->memberHook=null; $pending->resolve([]); + $outcome=end($f->reports); + cacheCheck($outcome['state']==='superseded' && $outcome['continuation_required']===true + && $outcome['passes']===1 && $f->file===$before,'Dirty budget exhaustion lost explicit continuation or mutated obsolete cache'); + $active=array_filter($f->timers,static fn($timer)=>$timer->active && $timer->delay===0.25); + cacheCheck(count($active)===1,'Dirty burst did not coalesce exactly one deferred continuation'); + $requests=$f->requests; cacheTick($f); + cacheCheck($f->requests===$requests+1 && $f->file===($change==='remove'?'':$admin) + && end($f->reports)['continuation_required']===false,'Deferred continuation did not project final cached membership'); + cacheCheck(count(array_filter($f->timers,static fn($timer)=>$timer->active))===0,'Successful continuation scheduled itself again'); +} +// Nested synchronous dirty requests can settle while the outer dispatch is still unwinding. +[$t,$f]=cacheFixture(true,1); +$f->memberHook=static function () use ($t,$f): array { + $f->memberHook=null; $f->members=[]; cacheEvent($t,$f); return []; +}; +$t->bootstrapReady(); cacheTick($f); +cacheCheck(end($f->reports)['continuation_required']===true && $f->writes===[], 'Synchronous dirty burst claimed a write'); +cacheTick($f); +cacheCheck($f->file==='' && count($f->writes)===1, 'Outermost dispatch unwind lost synchronous dirty continuation'); +// Writer refusal without dirty work and uncertain failures must never silently retry. +foreach (['superseded','blocked','failed-dirty'] as $mode) { + [$t,$f]=cacheFixture(true,1); + $f->writeHook=static function () use ($t,$f,$mode): array { + if ($mode==='failed-dirty') { cacheEvent($t,$f); throw new RuntimeException('synthetic uncertain writer'); } + return ['state'=>$mode]; + }; + $t->bootstrapReady(); cacheTick($f); $outcome=end($f->reports); + cacheCheck($outcome['continuation_required']===false + && count(array_filter($f->timers,static fn($timer)=>$timer->active))===0, + 'Generic writer refusal or failed dirty write silently retried'); + if ($mode==='failed-dirty') { cacheCheck($outcome['state']==='failed' && $outcome['integrity_uncertain']===true,'Failed dirty write lost uncertainty'); } +} +// A queued continuation stays behind stop, reconnect and failed-configuration barriers. +foreach (['stop','reconnect','config'] as $barrier) { + [$t,$f]=cacheFixture(true,1); $pending=new Deferred(); $f->memberHook=static fn()=>$pending->promise(); + $t->bootstrapReady(); cacheTick($f); $f->members=[]; cacheEvent($t,$f); + $f->memberHook=null; $pending->resolve([]); $requests=$f->requests; + $active=array_values(array_filter($f->timers,static fn($timer)=>$timer->active && $timer->delay===0.25)); + $late=$active[0]->callback; + if ($barrier==='stop') { $t->stop(); } + elseif ($barrier==='reconnect') { $refresh=new Deferred(); $f->refreshHook=static fn()=>$refresh->promise(); ($t->listeners()['reconnected'])(); } + else { + $f->configHook=static function (): void { throw new RuntimeException('synthetic config barrier'); }; + try { $t->configurationChanged(); } catch (RuntimeException $expected) {} + } + $late(); + cacheCheck(!$t->isReady() && $f->requests===$requests && $f->writes===[], 'Queued dirty continuation bypassed lifecycle barrier'); +} +// Report observers can stop the feature after continuation scheduling; no later callback may run. +[$t,$f]=cacheFixture(true,1); $pending=new Deferred(); $f->memberHook=static fn()=>$pending->promise(); +$t->bootstrapReady(); cacheTick($f); cacheEvent($t,$f); +$f->reportHook=static function () use ($t): void { $t->stop(); }; $pending->resolve([]); +cacheCheck(!$t->isReady() && count(array_filter($f->timers,static fn($timer)=>$timer->active))===0,'Reentrant report stop left continuation armed'); +// A report observer may service a newer event immediately; old cleanup must not enqueue again. +[$t,$f]=cacheFixture(true,1); $pending=new Deferred(); $f->memberHook=static fn()=>$pending->promise(); +$t->bootstrapReady(); cacheTick($f); cacheEvent($t,$f); $f->memberHook=null; +$f->reportHook=static function () use ($t,$f): void { + $f->reportHook=null; $f->members=[]; cacheEvent($t,$f); cacheTick($f); +}; +$pending->resolve([]); +cacheCheck($f->file==='' && count($f->writes)===1 + && count(array_filter($f->timers,static fn($timer)=>$timer->active))===0, + 'Old completion scheduled redundant work over newer report-triggered request'); +// A broken synchronous scheduler cannot turn dirty continuation into a recursive busy loop. +[$t,$f]=cacheFixture(true,1); $pending=new Deferred(); $f->memberHook=static fn()=>$pending->promise(); +$t->bootstrapReady(); cacheTick($f); cacheEvent($t,$f); $requests=$f->requests; +$f->scheduleHook=static function (float $delay,callable $callback): callable { $callback(); return static function (): void {}; }; +$pending->resolve([]); +cacheCheck($f->requests===$requests && $f->writes===[],'Synchronous continuation timer recursively dispatched work'); +echo "PASS cache sync ({$checks} assertions): initialized cache, event projection, refresh barrier, epochs and retained-file failures\n"; diff --git a/tests/local/admin-file-sync-profile.php b/tests/local/admin-file-sync-profile.php new file mode 100644 index 00000000..0ee5eb94 --- /dev/null +++ b/tests/local/admin-file-sync-profile.php @@ -0,0 +1,109 @@ +'civilizationbot.portable.v1','gitdir'=>'synthetic-gitdir','state_dir'=>'synthetic-state', + 'options'=>['owner_id'=>'','technician_id'=>'','civ13_guild_id'=>'770000000000000001', + 'channel_ids'=>[],'role_ids'=>['Admin'=>'880000000000000001']], + 'servers'=>['test1714'=>['enabled'=>false,'ip'=>'127.0.0.1','port'=>1714,'admin_file'=>$path]], + 'verification_source'=>['mode'=>'read-only','url'=>'http://civ13.com:55555/verified'], + 'admin_file_sync'=>['enabled'=>true,'max_bytes'=>4096,'debounce_seconds'=>0.25,'refresh_timeout_seconds'=>5.0], + ]; +} +$p=profileSyncFixture(); $o=PortableProfile::runtimeOptions($p); +profileSyncCheck($o['admin_file_sync']===array_merge($p['admin_file_sync'],['admin_file'=>$p['servers']['test1714']['admin_file']]),'Enabled config was not normalized/bound to sole explicit admin file'); +profileSyncCheck($o['verification_read_only']===true && $o['verify_url']==='http://civ13.com:55555/verified','Read-only hosted source changed'); +profileSyncCheck($p['servers']['test1714']['enabled']===false && !array_key_exists('servers',$o),'Admin file option enabled/rewrote game server'); +// Top-level policy always overwrites any options injection, including omitted policy. +$p=profileSyncFixture(); unset($p['admin_file_sync']); +$p['options']['admin_file_sync']=['enabled'=>true,'admin_file'=>'injected-path']; +profileSyncCheck(PortableProfile::runtimeOptions($p)['admin_file_sync']===['enabled'=>false],'Omitted policy inherited privileged options injection'); +$p['admin_file_sync']=['enabled'=>false]; +profileSyncCheck(PortableProfile::runtimeOptions($p)['admin_file_sync']===['enabled'=>false],'Disabled policy inherited privileged options injection'); +// Disabled mode need not configure the otherwise unavailable synchronization prerequisites. +unset($p['verification_source']); $p['options']['civ13_guild_id']=''; $p['options']['role_ids']=[]; unset($p['servers']); +profileSyncCheck(PortableProfile::runtimeOptions($p)['admin_file_sync']===['enabled'=>false],'Disabled synchronization required enabled-only dependencies'); +foreach ([null,false,true,0,'enabled',[],['enabled'=>0],['enabled'=>1],['enabled'=>'false'],['enabled'=>null], + ['enabled'=>false,'max_bytes'=>4096],['enabled'=>false,'extra'=>true]] as $config) { + $p=profileSyncFixture(); $p['admin_file_sync']=$config; + profileSyncRejects($p,'Malformed/extra disabled synchronization settings accepted'); +} +foreach (array_keys(profileSyncFixture()['admin_file_sync']) as $key) { + $p=profileSyncFixture(); unset($p['admin_file_sync'][$key]); + profileSyncRejects($p,'Required enabled setting omitted: '.$key); +} +foreach (['admin_file','path','server','role_ids','retry','extra'] as $key) { + $p=profileSyncFixture(); $p['admin_file_sync'][$key]='injected'; + profileSyncRejects($p,'Unknown enabled setting accepted: '.$key); +} +foreach ([1,16777216] as $value) { + $p=profileSyncFixture(); $p['admin_file_sync']['max_bytes']=$value; + profileSyncCheck(PortableProfile::runtimeOptions($p)['admin_file_sync']['max_bytes']===$value,'Valid integer byte boundary rejected'); +} +foreach ([0,-1,16777217,1.0,4096.5,'4096',true,false,null,NAN,INF] as $value) { + $p=profileSyncFixture(); $p['admin_file_sync']['max_bytes']=$value; + profileSyncRejects($p,'Invalid byte budget accepted'); +} +foreach (['debounce_seconds'=>5.0,'refresh_timeout_seconds'=>300.0] as $key=>$maximum) { + foreach ([0.001,1,(int)$maximum,$maximum] as $value) { + $p=profileSyncFixture(); $p['admin_file_sync'][$key]=$value; + $actual=PortableProfile::runtimeOptions($p)['admin_file_sync'][$key]; + profileSyncCheck(is_float($actual) && $actual===(float)$value,'Valid timer was not normalized to finite seconds: '.$key); + } + foreach ([0,0.0,-0.1,$maximum+0.01,NAN,INF,-INF,'1',true,false,null,[]] as $value) { + $p=profileSyncFixture(); $p['admin_file_sync'][$key]=$value; + profileSyncRejects($p,'Invalid timer accepted: '.$key); + } +} +foreach ([null,[],['mode'=>'read-write','url'=>'http://civ13.com:55555/verified'], + ['mode'=>'read-only','url'=>'https://civ13.com:55555/verified'], + ['mode'=>'read-only','url'=>'http://example.invalid/verified'], + ['mode'=>'read-only','url'=>'http://civ13.com:55555/verified','extra'=>true]] as $source) { + $p=profileSyncFixture(); $p['verification_source']=$source; + profileSyncRejects($p,'Unknown/unsafe source accepted for enabled sync'); +} +$p=profileSyncFixture(); unset($p['verification_source']); profileSyncRejects($p,'Enabled sync accepted absent read-only source'); +foreach (['',null,123,'guild','770000000000000001/extra'] as $guild) { + $p=profileSyncFixture(); $p['options']['civ13_guild_id']=$guild; + profileSyncRejects($p,'Enabled sync accepted missing/invalid target guild'); +} +$p=profileSyncFixture(); unset($p['options']['civ13_guild_id']); profileSyncRejects($p,'Enabled sync accepted omitted target guild'); +foreach ([[],null,'roles',false] as $roles) { + $p=profileSyncFixture(); $p['options']['role_ids']=$roles; + profileSyncRejects($p,'Enabled sync accepted missing/invalid role map'); +} +$p=profileSyncFixture(); unset($p['options']['role_ids']); profileSyncRejects($p,'Enabled sync accepted omitted role map'); +foreach ([[],null,false,'server'] as $servers) { + $p=profileSyncFixture(); $p['servers']=$servers; + profileSyncRejects($p,'Enabled sync accepted absent/malformed server list'); +} +$p=profileSyncFixture(); unset($p['servers']); profileSyncRejects($p,'Enabled sync accepted omitted server list'); +$p=profileSyncFixture(); $p['servers']['second']=$p['servers']['test1714']; profileSyncRejects($p,'Enabled sync invented multi-server support'); +foreach ([null,false,[],'server'] as $server) { + $p=profileSyncFixture(); $p['servers']['test1714']=$server; + profileSyncRejects($p,'Enabled sync accepted missing explicit server admin file'); +} +foreach (['','admins.txt','../admins.txt','D:admins.txt','file:///tmp/admins.txt','https://example.invalid/admins.txt',"/bad\0path",null,false,123] as $path) { + $p=profileSyncFixture(); $p['servers']['test1714']['admin_file']=$path; + profileSyncRejects($p,'Enabled sync accepted unsafe/nonabsolute admin path'); +} +$p=profileSyncFixture(); $p['servers']['test1714']['admin_file']=PHP_OS_FAMILY==='Windows'?'/current-drive/admins.txt':'D:\\wrong-platform\\admins.txt'; +profileSyncRejects($p,'Enabled sync accepted platform-inappropriate admin path'); +// load() is deliberately not exercised: its existing TEST1714/filesystem schema validation +// remains a separate contract. This test neither reads a profile nor probes path existence. +echo "PASS admin-file synchronization profile ({$checks} assertions): explicit opt-in, prerequisites, limits and path binding\n"; diff --git a/tests/local/admin-file-sync.php b/tests/local/admin-file-sync.php new file mode 100644 index 00000000..ed19281f --- /dev/null +++ b/tests/local/admin-file-sync.php @@ -0,0 +1,408 @@ +adds; $this->listeners[$event][]=$listener; if ($this->onHook!==null) { ($this->onHook)(); } } + public function removeListener(string $event,callable $listener): void { + ++$this->removes; + if ($this->removeHook!==null) { ($this->removeHook)(); } + $this->listeners[$event]=array_values(array_filter($this->listeners[$event]??[],static fn($existing)=>$existing!==$listener)); + } + public function emit(string $event,array $args=[]): void { foreach ($this->listeners[$event]??[] as $listener) { $listener(...$args); } } +} +final class SyncRepository { + public int $refreshes=0; public mixed $hook=null; + public function __construct(public array $items=[]) {} + public function get(string $field,string $id): mixed { return $this->items[$id]??null; } + public function has(string $id): bool { return array_key_exists($id,$this->items); } + public function freshen(): PromiseInterface { ++$this->refreshes; return $this->hook!==null?($this->hook)():resolve($this); } +} +final class SyncVerifier { + public bool $readOnly=true; public array $verified=[]; public int $refreshes=0; public int $lookups=0; public int $statusReads=0; + public mixed $statusHook=null; public mixed $refreshHook=null; + public function __construct(public object $fixture,public SyncRepository $members) {} + public function hostedStatus(): array { + ++$this->statusReads; + if ($this->statusHook!==null) { return ($this->statusHook)(); } + return $this->fixture->status; + } + public function refreshHosted(): PromiseInterface { + ++$this->refreshes; + return $this->refreshHook!==null?($this->refreshHook)():resolve($this->hostedStatus()); + } + public function getVerifiedMember(string $id): mixed { ++$this->lookups; return $this->members->get('id',$id); } +} +function syncFixture(bool $alreadyReady=false): array { + $f=(object)['now'=>1800000000,'timers'=>[],'timerCalls'=>0,'cancelCalls'=>0,'writes'=>[], + 'file'=>'previous file','reports'=>[],'writerHook'=>null,'guild'=>'770000000000000001','user'=>'990000000000000001']; + $f->status=['available'=>true,'stale'=>false,'has_valid_list'=>true,'refresh_in_progress'=>false,'records'=>1, + 'age_seconds'=>0,'last_success_utc'=>gmdate('Y-m-d\TH:i:s\Z',$f->now)]; + $roles=[]; $i=0; + foreach (GameServer::ADMIN_PERMISSIONS as $name=>$_) { $roles[$name]='88000000000000000'.(++$i); } + $guildRoles=new SyncRepository(array_fill_keys(array_values($roles),(object)['present'=>true])); + $held=new SyncRepository(array_fill_keys([$roles['Admin']],true)); + $members=new SyncRepository([$f->user=>(object)['id'=>$f->user,'roles'=>$held]]); + $guild=(object)['members'=>$members,'roles'=>$guildRoles]; + $discord=new SyncEmitter(new SyncRepository([$f->guild=>$guild])); + $verifier=new SyncVerifier($f,$members); $verifier->verified=[['discord'=>$f->user,'ss13'=>'Fake.Admin']]; + $loop=new class($f) { + public function __construct(private object $f) {} + public function addTimer(float $delay,callable $callback): object { ++$this->f->timerCalls; $timer=(object)['delay'=>$delay,'callback'=>$callback,'active'=>true]; $this->f->timers[]=$timer; return $timer; } + public function cancelTimer(object $timer): void { if ($timer->active) { $timer->active=false; ++$this->f->cancelCalls; } } + }; + $logger=new class($f) { public function __construct(private object $f) {} public function info(string $message,array $context=[]): void { $this->f->reports[]=$context; } }; + $app=(object)['verifier'=>$verifier,'civ13_guild_id'=>$f->guild,'role_ids'=>$roles,'discord'=>$discord,'loop'=>$loop,'logger'=>$logger,'ready'=>$alreadyReady]; + $writer=static function (string $bytes,int $generation,callable $guard) use ($f): mixed { + if ($f->writerHook!==null) { return ($f->writerHook)($bytes,$generation,$guard); } + if (!$guard()) { return ['state'=>'superseded']; } + $f->file=$bytes; $f->writes[]=$bytes; return ['state'=>'updated']; + }; + return [$app,$f,$writer]; +} +function syncSettings(): array { return ['enabled'=>true,'max_bytes'=>4096,'debounce_seconds'=>0.25,'refresh_timeout_seconds'=>5.0]; } +// Synthetic keys only: every enabled attachment below injects a memory writer. +function syncInertPath(string $file): string { return (PHP_OS_FAMILY === 'Windows' ? 'C:/inert/' : '/inert/').$file; } +function syncAttach(object $app,object $f,callable $writer): AdminFileSync { + $sync=AdminFileSync::attach($app,syncSettings(),syncInertPath('admins.txt'),$writer,static fn()=>$f->now); + compositionCheck($sync instanceof AdminFileSync,'Enabled composition missing'); return $sync; +} +function syncTick(object $f,float $delay=0.25): void { + foreach ($f->timers as $timer) { if ($timer->active && $timer->delay===$delay) { $timer->active=false; ($timer->callback)(); return; } } + throw new RuntimeException('Expected owned timer'); +} +function syncEvent(object $app,object $f,string $event='GUILD_MEMBER_UPDATE'): void { $app->discord->emit($event,[(object)['guild_id'=>$f->guild],$app->discord,null]); } + +$untouchable=new class { public function __get(string $key): never { throw new RuntimeException('Disabled app read'); } }; +$effect=static function (): never { throw new RuntimeException('Disabled effect'); }; +compositionCheck(AdminFileSync::attach($untouchable,[],'',$effect,$effect)===null,'Omitted option had effects'); +compositionCheck(AdminFileSync::attach($untouchable,['enabled'=>false],'',$effect,$effect)===null,'Disabled option had effects'); +// Shared pure preflight rejects complete-map errors before any bootstrap/client effects. +[$app,$f,$writer]=syncFixture(); +AdminFileSync::validateConfiguration(syncSettings(),syncInertPath('admins.txt'),$f->guild,$app->role_ids); +compositionCheck($app->discord->adds===0 && $f->timerCalls===0 && $app->verifier->refreshes===0,'Configuration preflight had runtime effects'); +foreach (array_keys(GameServer::ADMIN_PERMISSIONS) as $name) { + $roles=$app->role_ids; unset($roles[$name]); + try { AdminFileSync::validateConfiguration(syncSettings(),syncInertPath('admins.txt'),$f->guild,$roles); throw new RuntimeException('Missing full-map role accepted'); } + catch (InvalidArgumentException $expectedError) { compositionCheck(true,'Missing role rejected before init'); } +} +foreach (['duplicate','malformed'] as $bad) { + $roles=$app->role_ids; $roles['Admin']=$bad==='duplicate'?$roles['Owner']:'not-a-role'; + try { AdminFileSync::validateConfiguration(syncSettings(),syncInertPath('admins.txt'),$f->guild,$roles); throw new RuntimeException('Invalid role map accepted'); } + catch (InvalidArgumentException $expectedError) { compositionCheck(true,'Invalid role map rejected before init'); } +} +$bootstrap=file_get_contents(__DIR__.'/../../bot.php'); +$preflight=strpos($bootstrap,'\\Civ13\\AdminFileSync::validateConfiguration('); +compositionCheck($preflight!==false && $preflightdiscord->adds===8 && $f->timerCalls===0 && $app->verifier->refreshes===0 && $f->writes===[],'Construction fetched or wrote before init'); +$app->discord->emit('init',[$app->discord]); syncTick($f); +$expected="fakeadmin;Admin;16254|||\n"; +compositionCheck($f->file===$expected && $app->verifier->lookups===1,'Existing cached lookup/join failed'); +$guild=$app->discord->guilds->get('id',$f->guild); +compositionCheck($guild->members->refreshes===0 && $guild->roles->refreshes===0,'Init needlessly refreshed cache'); +$guild->members->items=[]; syncEvent($app,$f,'GUILD_MEMBER_REMOVE'); syncTick($f); +compositionCheck($f->file==='' && $guild->members->refreshes===0,'Cached departure did not revoke authoritatively'); +$sync->stop(); compositionCheck($app->discord->removes===8,'Owned listener cleanup missing'); +$before=$f->timerCalls; $app->discord->emit('init'); compositionCheck($f->timerCalls===$before,'Stopped listener remained active'); +// Retain unrelated listeners; stop cancels only owned scheduled work. +[$app,$f,$writer]=syncFixture(); $other=0; $app->discord->on('init',static function () use (&$other): void { ++$other; }); +$sync=syncAttach($app,$f,$writer); $app->discord->emit('init'); $sync->stop(); $app->discord->emit('init'); +compositionCheck($other===2 && count($app->discord->listeners['init'])===1 && $f->writes===[] && $f->cancelCalls===1,'Cleanup affected unrelated listener or wrote cancelled work'); +// Known late attachment schedules initial cached projection without waiting for init again. +[$app,$f,$writer]=syncFixture(true); $sync=syncAttach($app,$f,$writer); syncTick($f); +compositionCheck($f->file===$expected,'Late known-ready binding never synchronized'); $sync->stop(); +// Required roles/read-only verification are explicit; missing values produce no registration. +foreach (['readonly','roles','guild'] as $bad) { + [$app,$f,$writer]=syncFixture(); + if ($bad==='readonly') { $app->verifier->readOnly=false; } + elseif ($bad==='roles') { $app->role_ids=[]; } else { $app->civ13_guild_id=''; } + try { syncAttach($app,$f,$writer); throw new LogicException('Bad configuration accepted'); } + catch (LogicException $error) { compositionCheck($error->getMessage()!=='Bad configuration accepted','Bad configuration accepted'); } + compositionCheck($app->discord->adds===0 && $app->verifier->refreshes===0 && $f->writes===[],'Invalid configuration had effects'); +} +// Reconnect uses the existing library repositories, and no member event starts HTTP. +[$app,$f,$writer]=syncFixture(true); $sync=syncAttach($app,$f,$writer); syncTick($f); +$guild=$app->discord->guilds->get('id',$f->guild); $pending=new Deferred(); $guild->members->hook=static fn()=>$pending->promise(); +$app->discord->emit('reconnected'); $guild->members->items=[]; syncEvent($app,$f,'GUILD_MEMBER_REMOVE'); +compositionCheck($guild->members->refreshes===1 && $guild->roles->refreshes===1 && $f->file===$expected,'Refresh barrier or repository reuse failed'); +$pending->resolve(null); syncTick($f); compositionCheck($f->file==='' && $guild->members->refreshes===1,'Refresh completion did not use updated cache'); $sync->stop(); +// One repository failure must not release ownership while the other refresh is pending. +foreach (['reject','throw'] as $failure) { + [$app,$f,$writer]=syncFixture(true); $sync=syncAttach($app,$f,$writer); syncTick($f); + $guild=$app->discord->guilds->get('id',$f->guild); $memberPending=new Deferred(); $rolePending=new Deferred(); + $guild->members->hook=static function () use ($failure,$memberPending): PromiseInterface { if ($failure==='throw') { throw new RuntimeException('synthetic member refresh'); } return $memberPending->promise(); }; + $guild->roles->hook=static fn()=>$rolePending->promise(); $app->discord->emit('reconnected'); + if ($failure==='reject') { $memberPending->reject(new RuntimeException('synthetic member rejection')); } + $app->discord->emit('reconnected'); + compositionCheck($guild->members->refreshes===1 && $guild->roles->refreshes===1 && $f->file===$expected,'Early repository failure overlapped an outstanding refresh'); + $guild->members->hook=null; $guild->roles->hook=null; $guild->members->items=[]; $rolePending->resolve(null); + compositionCheck($guild->members->refreshes===2 && $guild->roles->refreshes===2,'Received reconnect was not serviced after both repositories drained'); + syncTick($f); compositionCheck($f->file==='','Coalesced refresh failed to project latest cache'); $sync->stop(); +} +// Stop during the first repository call prevents starting the second repository effect. +[$app,$f,$writer]=syncFixture(true); $sync=syncAttach($app,$f,$writer); syncTick($f); +$guild=$app->discord->guilds->get('id',$f->guild); +$guild->members->hook=static function () use ($sync): PromiseInterface { $sync->stop(); return resolve(null); }; +$app->discord->emit('reconnected'); +compositionCheck($guild->members->refreshes===1 && $guild->roles->refreshes===0 && $f->file===$expected,'Reentrant stop still started role refresh'); +// Writer precommit rejects identity changes even when record count and success timestamp match. +foreach (['records','status','verifier','config','stop'] as $change) { + [$app,$f,$writer]=syncFixture(true); $pending=new Deferred(); $guard=null; + $f->writerHook=static function (string $bytes,int $generation,callable $canCommit) use (&$guard,$pending): PromiseInterface { $guard=$canCommit; return $pending->promise(); }; + $sync=syncAttach($app,$f,$writer); syncTick($f); + compositionCheck(is_callable($guard) && $guard(),'Initial paired precommit failed'); + if ($change==='records') { $app->verifier->verified[0]['ss13']='differentidentity'; } + elseif ($change==='status') { $f->status['refresh_in_progress']=true; } + elseif ($change==='verifier') { $app->verifier=clone $app->verifier; } + elseif ($change==='config') { $app->role_ids['Admin']='880000000000000099'; } + else { $sync->stop(); } + compositionCheck(!$guard() && $f->file==='previous file','Obsolete identity/config/stop passed commit guard'); + $pending->resolve(['state'=>'superseded']); $sync->stop(); +} +// Failure status and mismatched pairing never mean a valid empty authoritative list. +foreach (['unavailable','count','capture'] as $bad) { + [$app,$f,$writer]=syncFixture(true); + if ($bad==='unavailable') { $f->status['available']=false; $f->status['stale']=true; } + elseif ($bad==='count') { $f->status['records']=2; } + else { $app->verifier->statusHook=static function () use ($app,$f): array { $s=$f->status; $s['age_seconds']=$app->verifier->statusReads; return $s; }; } + $sync=syncAttach($app,$f,$writer); syncTick($f); + compositionCheck($f->file==='previous file' && $f->writes===[] && end($f->reports)['state']==='blocked','Invalid verification was written'); $sync->stop(); +} +// Existing feed normalization/ambiguity protections remain at the projection boundary. +foreach (['duplicate-id','duplicate-ckey','unsafe-admin'] as $bad) { + [$app,$f,$writer]=syncFixture(true); + if ($bad==='duplicate-id') { $app->verifier->verified[]=['discord'=>$f->user,'ss13'=>'otheridentity']; } + elseif ($bad==='duplicate-ckey') { $app->verifier->verified[]=['discord'=>'990000000000000099','ss13'=>'fakeadmin']; } + else { $app->verifier->verified[0]['ss13']='unsafe;admin'; } + $f->status['records']=count($app->verifier->verified); + $sync=syncAttach($app,$f,$writer); syncTick($f); + compositionCheck($f->file==='previous file' && $f->writes===[] && end($f->reports)['state']==='blocked','Ambiguous or unsafe feed identity was written'); $sync->stop(); +} +[$app,$f,$writer]=syncFixture(true); $app->verifier->verified=[]; $f->status['records']=0; +$sync=syncAttach($app,$f,$writer); syncTick($f); +compositionCheck($f->file==='' && count($f->writes)===1,'Valid successful empty identity snapshot was not authoritative'); $sync->stop(); + +// Changed configuration is rejected before starting source or cache refresh effects. +[$app,$f,$writer]=syncFixture(true); $sync=syncAttach($app,$f,$writer); $app->role_ids['Admin']='880000000000000099'; syncTick($f); +$guild=$app->discord->guilds->get('id',$f->guild); $app->discord->emit('reconnected'); +compositionCheck($app->verifier->refreshes===0 && $guild->members->refreshes===0 && $guild->roles->refreshes===0 && $f->writes===[],'Changed configuration started refresh effects'); $sync->stop(); +// Preserve the original registration error even when owned cleanup also fails. +[$app,$f,$writer]=syncFixture(); +$app->discord->onHook=static function (): void { throw new RuntimeException('original registration failure'); }; +$app->discord->removeHook=static function (): void { throw new RuntimeException('cleanup failure'); }; +try { syncAttach($app,$f,$writer); throw new LogicException('Registration failure swallowed'); } +catch (RuntimeException $error) { compositionCheck($error->getMessage()==='original registration failure','Cleanup masked original registration failure'); } +// Cold Civ13 ordering: verifier is supplied by an earlier init handler before composition attaches. +[$app,$f,$writer]=syncFixture(); $verifier=$app->verifier; unset($app->verifier); $sync=null; +$app->discord->on('init',static function () use ($app,$verifier): void { $app->verifier=$verifier; }); +$app->discord->on('init',static function () use ($app,$f,$writer,&$sync): void { $app->ready=true; $sync=syncAttach($app,$f,$writer); }); +$app->discord->emit('init'); syncTick($f); +compositionCheck($sync instanceof AdminFileSync && $f->file===$expected,'Cold init source/setup ordering failed'); $sync->stop(); +// Runtime attachment unavailability fences this feature, not unrelated init work. +[$app,$f,$writer]=syncFixture(); $app->verifier->readOnly=false; $service=null; $continued=false; $reported=false; +$app->discord->on('init',static function () use ($app,$f,$writer,&$service,&$continued,&$reported): void { + $app->ready=true; + try { $service=syncAttach($app,$f,$writer); } + catch (Throwable) { $service=null; $reported=true; } + $continued=true; +}); +$app->discord->emit('init'); +compositionCheck($service===null && $reported && $continued && $f->writes===[],'Runtime attachment failure aborted unrelated init or granted writes'); +$core=file_get_contents(__DIR__.'/../../src/Civ13/Civ13.php'); +compositionCheck(str_contains($core,'Admin file synchronization inactive: runtime attachment failed') + && str_contains($core,'catch (\\Throwable)'), 'Core feature-only attachment failure guard missing'); + +// An external verifier completion consumes its already-replaced cache, not another refresh. +[$app,$f,$writer]=syncFixture(true); $sync=syncAttach($app,$f,$writer); syncTick($f); +$refreshes=$app->verifier->refreshes; +$app->verifier->verified=[]; $f->status['records']=0; +$sync->verificationChanged(); syncTick($f); +compositionCheck($f->file==='' && $app->verifier->refreshes===$refreshes,'Verifier-only removal refetched or retained old authority'); +$app->verifier->verified=[['discord'=>$f->user,'ss13'=>'newidentity']]; $f->status['records']=1; +$sync->verificationChanged(); $sync->verificationChanged(); syncTick($f); +compositionCheck($f->file==="newidentity;Admin;16254|||\n" && $app->verifier->refreshes===$refreshes,'Verifier-only addition refetched or failed to coalesce'); +compositionCheck(count(array_filter($f->timers,static fn($timer)=>$timer->active))===0,'Verifier projection scheduled another burst'); +$sync->stop(); $effects=[$f->timerCalls,$app->verifier->refreshes,count($f->writes)]; +$sync->verificationChanged(); +compositionCheck([$f->timerCalls,$app->verifier->refreshes,count($f->writes)]===$effects,'Stopped verifier hook caused effects'); + +// Recovery from unavailable verification serves the current cache with no extra source call. +[$app,$f,$writer]=syncFixture(true); $f->status['available']=false; $f->status['stale']=true; +$sync=syncAttach($app,$f,$writer); syncTick($f); $refreshes=$app->verifier->refreshes; +$f->status['available']=true; $f->status['stale']=false; $sync->verificationChanged(); syncTick($f); +compositionCheck($f->file===$expected && $app->verifier->refreshes===$refreshes,'Verifier recovery refetched or remained blocked'); $sync->stop(); + +// Coordinator-owned completion is already consumed, including synchronous callback reentrancy. +foreach (['synchronous','pending'] as $completion) { + [$app,$f,$writer]=syncFixture(true); $sync=syncAttach($app,$f,$writer); $pending=new Deferred(); + $replace=static function () use ($app,$f,$sync): void { + $app->verifier->verified=[]; $f->status['records']=0; $sync->verificationChanged(); + }; + $app->verifier->refreshHook=static function () use ($completion,$replace,$pending): PromiseInterface { + if ($completion==='synchronous') { $replace(); return resolve(null); } + return $pending->promise(); + }; + syncTick($f); + if ($completion==='pending') { $replace(); $pending->resolve(null); } + compositionCheck($f->file==='' && $app->verifier->refreshes===1 && count($f->writes)===1, + 'Already-consumed verifier completion self-dirtied or refetched'); + compositionCheck(count(array_filter($f->timers,static fn($timer)=>$timer->active))===0,'Already-consumed completion scheduled itself'); + $sync->stop(); +} + +// External completion fences a delayed writer even when only the success timestamp changes. +foreach (['removed','unchanged'] as $replacement) { + [$app,$f,$writer]=syncFixture(true); $pending=new Deferred(); $guard=null; + $f->writerHook=static function (string $bytes,int $generation,callable $canCommit) use ($pending,&$guard): PromiseInterface { + $guard=$canCommit; return $pending->promise(); + }; + $sync=syncAttach($app,$f,$writer); syncTick($f); $refreshes=$app->verifier->refreshes; + compositionCheck($guard(),'Pending initial verifier projection had no authority'); + if ($replacement==='removed') { $app->verifier->verified=[]; $f->status['records']=0; } + ++$f->now; $f->status['last_success_utc']=gmdate('Y-m-d\\TH:i:s\\Z',$f->now); + $sync->verificationChanged(); + compositionCheck(!$guard(),'Verifier replacement left a stale writer authorized'); + $f->writerHook=null; $pending->resolve(['state'=>'superseded']); + compositionCheck($f->file===($replacement==='removed'?'':$expected) + && $app->verifier->refreshes===$refreshes && count($f->writes)===1, + 'Dirty verifier-only continuation refetched or lost latest cache'); $sync->stop(); +} + +// The real owner bridge rejects retired verifiers and leaves stopped owners inert. +[$app,$f,$writer]=syncFixture(true); $sync=syncAttach($app,$f,$writer); syncTick($f); +$owner=(new ReflectionClass(\Civ13\Civ13::class))->newInstanceWithoutConstructor(); +$currentVerifier=(new ReflectionClass(\Civ13\Verifier::class))->newInstanceWithoutConstructor(); +$retiredVerifier=(new ReflectionClass(\Civ13\Verifier::class))->newInstanceWithoutConstructor(); +$owner->verifier=$currentVerifier; +(new ReflectionProperty(\Civ13\Civ13::class,'adminFileSync'))->setValue($owner,$sync); +$effects=[$f->timerCalls,$app->verifier->refreshes,count($f->writes)]; +$owner->hostedVerificationRefreshed($retiredVerifier); +compositionCheck([$f->timerCalls,$app->verifier->refreshes,count($f->writes)]===$effects,'Retired verifier notified current composition'); +$owner->hostedVerificationRefreshed($currentVerifier); syncTick($f); +compositionCheck($app->verifier->refreshes===$effects[1] && count($f->writes)===$effects[2]+1,'Current owner bridge did not serve the cache-only projection'); +$sync->stop(); $effects=[$f->timerCalls,$app->verifier->refreshes,count($f->writes)]; +$owner->hostedVerificationRefreshed($currentVerifier); +compositionCheck([$f->timerCalls,$app->verifier->refreshes,count($f->writes)]===$effects,'Stopped owner completion caused effects'); +unset($owner,$currentVerifier,$retiredVerifier); + +// A completion during reconnect remains behind the existing cache-readiness barrier. +[$app,$f,$writer]=syncFixture(true); $sync=syncAttach($app,$f,$writer); syncTick($f); $refreshes=$app->verifier->refreshes; +$guild=$app->discord->guilds->get('id',$f->guild); $pending=new Deferred(); $guild->members->hook=static fn()=>$pending->promise(); +$app->discord->emit('reconnected'); $app->verifier->verified=[]; $f->status['records']=0; $sync->verificationChanged(); +compositionCheck($f->file===$expected,'Verifier completion bypassed reconnect barrier'); +$pending->resolve(null); syncTick($f); +compositionCheck($f->file==='' && $app->verifier->refreshes===$refreshes,'Reconnect discarded cache-only projection or refetched'); $sync->stop(); + +// Reload command -> real Civ13::stop -> replacement composition, while an old write waits. +// Constructorless owner and existing inert facade only; no Discord client, loop run, +// supervisor child, profile load/edit or real admin file. Only an owned temporary marker. +final class SyncReloadOwner extends \Civ13\Civ13 { + public Deferred $replyPending; + public array $replyTexts=[]; + public function reply(\Discord\Parts\Channel\Message|\Discord\Parts\Thread\Thread $message, + string $content,string $file_name='message.txt',bool $prevent_mentions=false): PromiseInterface { + $this->replyTexts[]=$content; return $this->replyPending->promise(); + } +} +final class SyncShutdownLogger extends \Psr\Log\AbstractLogger { + public array $guardAtShutdown=[]; + public function __construct(private \Closure $guard) {} + public function log($level,string|\Stringable $message,array $context=[]): void { + if ((string)$message==='Shutting down') { $this->guardAtShutdown[]=($this->guard)(); } + } +} +foreach (['same-target','changed-target'] as $targetChange) { + foreach (['resolved','rejected'] as $replyOutcome) { + $state=sys_get_temp_dir().'/admin-reload-fence-'.bin2hex(random_bytes(8)); + if (!mkdir($state)) { throw new RuntimeException('Could not create isolated reload marker directory'); } + $oldSync=null; $newSync=null; $owner=null; + try { + [$oldApp,$oldF,$unusedWriter]=syncFixture(true); + $oldTarget=syncInertPath('old-admins.txt'); + $newTarget=$targetChange==='same-target'?$oldTarget:syncInertPath('new-admins.txt'); + $files=[$oldTarget=>'previous old file']; + if ($newTarget!==$oldTarget) { $files[$newTarget]='previous new file'; } + $commits=[]; $pendingWrite=null; $oldWrite=new Deferred(); + $oldWriter=static function (string $bytes,int $generation,callable $guard) use (&$pendingWrite,$oldWrite): PromiseInterface { + $pendingWrite=['bytes'=>$bytes,'guard'=>$guard]; return $oldWrite->promise(); + }; + $oldSync=AdminFileSync::attach($oldApp,syncSettings(),$oldTarget,$oldWriter,static fn()=>$oldF->now); + syncTick($oldF); + compositionCheck($pendingWrite!==null && ($pendingWrite['guard'])(),'Old write did not reach a live precommit barrier'); + + // Keep actual stop() inherited: an override would hide the integration under test. + $owner=(new ReflectionClass(SyncReloadOwner::class))->newInstanceWithoutConstructor(); + $owner->portable_state_dir=$state; $owner->replyPending=new Deferred(); + $owner->logger=new SyncShutdownLogger(\Closure::fromCallable($pendingWrite['guard'])); + $owner->loop=new \React\EventLoop\StreamSelectLoop(); // never run + (new ReflectionProperty(\Civ13\Civ13::class,'adminFileSync'))->setValue($owner,$oldSync); + $command=new \Civ13\MessageCommand\Commands\ReloadConfig($owner); + $message=(new ReflectionClass(\Discord\Parts\Channel\Message::class))->newInstanceWithoutConstructor(); + $replyState='pending'; + $command($message,'reloadconfig',[])->then( + static function () use (&$replyState): void { $replyState='resolved'; }, + static function (\Throwable $error) use (&$replyState): void { $replyState='rejected'; }); + compositionCheck(is_file($state.'/reload.request') && count($owner->replyTexts)===1,'Actual reload command did not request one relaunch'); + compositionCheck($replyState==='pending' && ($pendingWrite['guard'])(),'Fixture confused reload acknowledgement with completed shutdown'); + if ($replyOutcome==='resolved') { $owner->replyPending->resolve(null); } + else { $owner->replyPending->reject(new RuntimeException('Synthetic reload reply failure')); } + compositionCheck($replyState===$replyOutcome,'Reload reply outcome lost'); + compositionCheck($owner->logger->guardAtShutdown===[false],'Pending old write remained valid at first shutdown side effect'); + compositionCheck(!($pendingWrite['guard'])() && $oldApp->discord->removes===8,'Actual shutdown did not revoke old write/listeners'); + compositionCheck(\Civ13\PortableProcess::consumeReload($state) && !\Civ13\PortableProcess::consumeReload($state),'Reload marker was not consumed once'); + + [$newApp,$newF,$unusedWriter]=syncFixture(true); + $guild=$newApp->discord->guilds->get('id',$newF->guild); + $newF->guild='770000000000000002'; $newApp->civ13_guild_id=$newF->guild; + $newApp->discord->guilds->items=[$newF->guild=>$guild]; + foreach ($newApp->role_ids as $name=>$role) { $newApp->role_ids[$name]='89'.substr($role,2); } + $guild->roles->items=array_fill_keys(array_values($newApp->role_ids),(object)['present'=>true]); + $guild->members->items[$newF->user]->roles=new SyncRepository([$newApp->role_ids['Owner']=>true]); + $newApp->verifier->verified=[['discord'=>$newF->user,'ss13'=>'Reloaded.Admin']]; + $newWriter=static function (string $bytes,int $generation,callable $guard) use (&$files,&$commits,$newTarget): array { + if (!$guard()) { return ['state'=>'superseded']; } + $files[$newTarget]=$bytes; $commits[]=$newTarget; return ['state'=>'updated']; + }; + $newSync=AdminFileSync::attach($newApp,syncSettings(),$newTarget,$newWriter,static fn()=>$newF->now); + syncTick($newF); + compositionCheck($newApp->verifier!==$oldApp->verifier && $newApp->role_ids!==$oldApp->role_ids,'Replacement did not change source and role authority'); + compositionCheck($files[$newTarget]==="reloadedadmin;Host;65535|||\n" && $commits===[$newTarget],'Replacement failed to commit its new source/role/target projection'); + $afterReplacement=$files; + // Drain the real old coordinator's delayed adapter only after the new write. + // The adapter honors its final guard immediately before the memory mutation. + $oldResult='superseded'; + if (($pendingWrite['guard'])()) { + $files[$oldTarget]=$pendingWrite['bytes']; $commits[]=$oldTarget; $oldResult='updated'; + } + $oldWrite->resolve(['state'=>$oldResult]); + compositionCheck($oldResult==='superseded' && $files===$afterReplacement && $commits===[$newTarget],'Late old write overwrote replacement or retired target'); + compositionCheck($newTarget===$oldTarget || $files[$oldTarget]==='previous old file','Changed target incorrectly cleared or rewrote old destination'); + $oldEffects=[$oldF->timerCalls,$oldApp->verifier->refreshes,count($commits)]; + $oldApp->discord->emit('init'); $oldApp->discord->emit('reconnected'); syncEvent($oldApp,$oldF); + compositionCheck([$oldF->timerCalls,$oldApp->verifier->refreshes,count($commits)]===$oldEffects,'Old callbacks regained authority after replacement'); + } finally { + $oldSync?->stop(); $newSync?->stop(); unset($owner); + if (is_file($state.'/reload.request')) { unlink($state.'/reload.request'); } + rmdir($state); + } + } +} + +echo "PASS portable admin composition ({$checks} assertions): disabled effects, cached join, existing refresh, paired identities, owned cleanup and reload fencing\n"; diff --git a/tests/local/admin-file-writer.php b/tests/local/admin-file-writer.php new file mode 100644 index 00000000..2ec64295 --- /dev/null +++ b/tests/local/admin-file-writer.php @@ -0,0 +1,128 @@ +writes; + if ($this->failure==='zero') { return 0; } + if ($this->failure==='false') { return false; } + if ($this->failure==='throw') { throw new RuntimeException('Synthetic write failure'); } + if ($this->failure==='partial' && $this->writes>1) { return false; } + return parent::writeChunk($file,substr($bytes,0,3)); + } + protected function truncate($file,int $length): bool { + return $this->failure==='truncate'?false:parent::truncate($file,$length); + } + protected function flush($file): bool { + return $this->failure==='flush'?false:parent::flush($file); + } +} + +$directory=sys_get_temp_dir().DIRECTORY_SEPARATOR.'admin-writer-'.bin2hex(random_bytes(8)); +writerCheck(mkdir($directory),'Could not create temporary fixture directory'); +$root=realpath($directory); writerCheck(is_string($root),'Fixture canonical path unavailable'); +$path=$root.DIRECTORY_SEPARATOR.'admins.txt'; $link=$root.DIRECTORY_SEPARATOR.'link.txt'; +$reader=null; $holder=null; +try { + file_put_contents($path,'baseline-tail'); + $before=lstat($path); + writerCheck(is_array($before) && is_int($before['ino']) && $before['ino']!==0 && PHP_INT_SIZE>=8, + 'Platform lacks reliable nonzero file identity: writer must remain blocked, not weaken checks'); + $writer=new AdminFileWriter($path,4096); + $missing=$root.DIRECTORY_SEPARATOR.'missing.txt'; + $r=(new AdminFileWriter($missing,4096))('candidate',7,static fn()=>false); + writerCheck($r['state']==='superseded' && !file_exists($missing),'Initial guard created missing path'); + $r=$writer('candidate',7,static fn()=>false); + writerCheck($r['state']==='superseded' && file_get_contents($path)==='baseline-tail','Initial guard modified baseline'); + $r=$writer('candidate',7,static function (): bool { throw new RuntimeException('Synthetic guard error'); }); + writerCheck($r['state']==='superseded' && file_get_contents($path)==='baseline-tail','Throwing guard modified baseline'); + foreach ([2,3] as $denyAt) { + $checks=0; + $r=$writer('candidate',7,static function () use (&$checks,$denyAt): bool { return ++$checks<$denyAt; }); + writerCheck($r['state']==='superseded' && $checks===$denyAt && file_get_contents($path)==='baseline-tail','Under-lock/final guard modified baseline'); + writerUnlocked($path); + } + // The second guard is called while the exclusive lock is held. + $checks=0; $observedLock=false; + $r=$writer('candidate',7,static function () use (&$checks,&$observedLock,$path): bool { + if (++$checks!==2) { return true; } + $second=@fopen($path,'r+b'); + if ($second===false) { $observedLock=true; return false; } + $acquired=@flock($second,LOCK_EX|LOCK_NB); + $observedLock=!$acquired; + if ($acquired) { flock($second,LOCK_UN); } + fclose($second); return false; + }); + writerCheck($r['state']==='superseded' && $observedLock && file_get_contents($path)==='baseline-tail','Guard ran without exclusive lock'); + // One attempt while another handle holds the lock: no wait/retry/mutation. + $holder=fopen($path,'r+b'); writerCheck(flock($holder,LOCK_EX|LOCK_NB),'Could not lock fixture'); + $start=microtime(true); $r=$writer('candidate',7,static fn()=>true); $elapsed=microtime(true)-$start; + flock($holder,LOCK_UN); fclose($holder); $holder=null; + writerCheck($r['state']==='blocked' && $elapsed<5 && file_get_contents($path)==='baseline-tail','Lock contention did not promptly block untouched'); + $r=$writer('baseline-tail',7,static fn()=>true); + writerCheck($r['state']==='unchanged','Exact content not checked unchanged'); + $checks=0; + $r=$writer('baseline-tail',7,static function () use (&$checks): bool { return ++$checks<3; }); + writerCheck($r['state']==='superseded','Unchanged acknowledged after final guard failed'); + $reader=fopen($path,'rb'); $opened=fstat($reader); + $r=$writer("fakealpha;Fixture;3|||\n",7,static fn()=>true); + writerCheck($r['state']==='updated' && file_get_contents($path)==="fakealpha;Fixture;3|||\n",'LF update failed'); + rewind($reader); writerCheck(stream_get_contents($reader)==="fakealpha;Fixture;3|||\n",'Update replaced reader identity'); + clearstatcache(true); $after=lstat($path); + writerCheck($before['dev']===$after['dev'] && $before['ino']===$after['ino'] && $opened['ino']===$after['ino'],'Identity changed'); + writerCheck(($before['mode']&0777)===($after['mode']&0777),'Filesystem mode changed'); + $r=$writer('x',7,static fn()=>true); writerCheck($r['state']==='updated' && file_get_contents($path)==='x','Short write retained stale tail'); + $r=$writer('',7,static fn()=>true); writerCheck($r['state']==='updated' && file_get_contents($path)==='','Empty authoritative update failed'); + rewind($reader); writerCheck(stream_get_contents($reader)==='','Empty update lost existing reader'); + fclose($reader); $reader=null; + writerCheck($writer('',7,static fn()=>true)['state']==='unchanged','Existing empty file not checked unchanged'); + writerCheck((new AdminFileWriter($missing,4096))('x',7,static fn()=>true)['state']==='blocked' && !file_exists($missing),'Missing file was created'); + $missingParent=$root.DIRECTORY_SEPARATOR.'absent'.DIRECTORY_SEPARATOR.'admins.txt'; + writerCheck((new AdminFileWriter($missingParent,4096))('x',7,static fn()=>true)['state']==='blocked' && !file_exists(dirname($missingParent)),'Parent directory created'); + writerCheck((new AdminFileWriter($root,4096))('x',7,static fn()=>true)['state']==='blocked','Directory accepted as target'); + file_put_contents($path,'baseline'); + writerCheck((new AdminFileWriter($path,4))('x',7,static fn()=>true)['state']==='blocked' && file_get_contents($path)==='baseline','Oversized existing file mutated'); + writerCheck($writer(str_repeat('x',4097),7,static fn()=>true)['state']==='blocked' && file_get_contents($path)==='baseline','Oversized candidate mutated'); + // Temporary symlink fixture only; never change privilege settings to enable this test. + if (function_exists('symlink') && @symlink($path,$link)) { + writerCheck((new AdminFileWriter($link,4096))('x',7,static fn()=>true)['state']==='blocked' && file_get_contents($path)==='baseline','Observed symlink was followed for writing'); + unlink($link); + } else { echo "SKIP symlink fixture: platform did not permit creation; writer rejection checks remain enabled\n"; } + foreach (['zero','false','throw','partial','truncate','flush'] as $failure) { + file_put_contents($path,'old-baseline'); $failing=new FailingAdminFileWriter($path,4096); $failing->failure=$failure; + writerThrows(fn()=>$failing('NEW-content',7,static fn()=>true),'Mutation failure was reported as terminal success'); + if (in_array($failure,['zero','false','throw'],true)) { writerCheck(file_get_contents($path)==='old-baseline','First-write failure unexpectedly mutated fixture'); } + if ($failure==='partial') { writerCheck(str_starts_with(file_get_contents($path),'NEW'),'Partial failure was silently rolled back'); } + writerUnlocked($path); + } + file_put_contents($path,'old-baseline'); $short=new FailingAdminFileWriter($path,4096); + writerCheck($short('NEW-content',7,static fn()=>true)['state']==='updated' && $short->writes>1 && file_get_contents($path)==='NEW-content','Short-write loop failed'); + foreach (['relative.txt',$root.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'escape.txt',"bad\0path",'https://example.invalid/admins.txt'] as $invalid) { + try { new AdminFileWriter($invalid,4096); throw new LogicException('Unsafe path accepted'); } + catch (InvalidArgumentException $expected) {} + } + echo "PASS existing-file writer: guards, nonblocking contention, checked unchanged, LF/empty/truncation, identity and deterministic failures\n"; +} finally { + if (is_resource($reader)) { fclose($reader); } + if (is_resource($holder)) { flock($holder,LOCK_UN); fclose($holder); } + if (is_link($link)) { unlink($link); } + if (is_file($path)) { unlink($path); } + rmdir($root); +} diff --git a/tests/local/admin-member-snapshot.php b/tests/local/admin-member-snapshot.php new file mode 100644 index 00000000..d8dfa93a --- /dev/null +++ b/tests/local/admin-member-snapshot.php @@ -0,0 +1,138 @@ +'pending']; + $promise->then(static function (array $result) use (&$outcome): void { $outcome=['state'=>'resolved','result'=>$result]; }, + static function (Throwable $error) use (&$outcome): void { $outcome=['state'=>'rejected']; }); + return $outcome; +} +function memberFixture(int $maxPages=5, int $maxMembers=100, float $timeout=10): array { + $f=(object)['now'=>0.0,'roles'=>['880000000000000001'],'pages'=>[], + 'life'=>['guild_id'=>'770000000000000001','connected'=>true,'epoch'=>1,'generation'=>7], + 'roleHook'=>null,'pageHook'=>null,'deadlineHook'=>null,'observeHook'=>null, + 'roleCalls'=>0,'pageCalls'=>[],'timerCalls'=>0,'cancelCalls'=>0,'expire'=>null]; + $p=new AdminMemberSnapshotProvider( + static function (string $guild) use ($f): mixed { + memberCheck($guild==='770000000000000001','Wrong roles guild'); ++$f->roleCalls; + return $f->roleHook!==null?($f->roleHook)():$f->roles; + }, + static function (string $guild,string $after,int $limit) use ($f): mixed { + memberCheck($guild==='770000000000000001' && $limit===1000,'Wrong member endpoint arguments'); + $f->pageCalls[]=$after; + return $f->pageHook!==null?($f->pageHook)($after):array_shift($f->pages); + }, + static fn(string $guild)=>$f->observeHook!==null?($f->observeHook)($guild):$f->life, + static fn()=>$f->now, + static function (float $delay,callable $expire) use ($f): mixed { + memberCheck($delay>0,'Invalid deadline duration'); ++$f->timerCalls; $f->expire=$expire; + if ($f->deadlineHook!==null) { return ($f->deadlineHook)($expire); } + return static function () use ($f): void { ++$f->cancelCalls; }; + }, $maxPages,$maxMembers,$timeout); + return [$p,$f]; +} +function memberRow(string $id): array { return ['id'=>$id,'roles'=>['880000000000000001']]; } +$guild='770000000000000001'; +[$p,$f]=memberFixture(); $f->pages=[[memberRow('990000000000000001')],[]]; +$o=memberOutcome($p->snapshot($guild,7)); +memberCheck($o['state']==='resolved' && $o['result']['complete']===true && count($o['result']['members'])===1 + && $o['result']['pages']===2 && $f->pageCalls===['0','990000000000000001'] && $f->roleCalls===2 && $f->cancelCalls===1,'Short nonempty page did not require terminal empty'); +[$p,$f]=memberFixture(1,0); $f->pages=[[]]; $o=memberOutcome($p->snapshot($guild,7)); +memberCheck($o['state']==='resolved' && $o['result']['members']===[] && $o['result']['pages']===1,'Valid empty first page rejected'); +// Decimal-string cursor uses greatest ID even in an unsorted page, without integer casts. +[$p,$f]=memberFixture(); $f->pages=[[memberRow('99999999999999999999'),memberRow('99999999999999999')],[]]; +$o=memberOutcome($p->snapshot($guild,7)); +memberCheck($o['state']==='resolved' && $f->pageCalls===['0','99999999999999999999'] && $o['result']['members'][0]['id']==='99999999999999999','Snowflake overflow/order error'); +foreach ([null,false,['error'=>'denied'],[null],[['id'=>'00000000000000001','roles'=>[]]], + [['id'=>990000000000000001,'roles'=>[]]],[['id'=>'990000000000000001','roles'=>['880000000000000002']]], + [['id'=>'990000000000000001','roles'=>['880000000000000001','880000000000000001']]], + [memberRow('990000000000000001'),memberRow('990000000000000001')]] as $bad) { + [$p,$f]=memberFixture(); $f->pages=[$bad,[]]; + memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected' && count($f->pageCalls)===1 && $f->roleCalls===1,'Invalid page gave partial authority or continued'); +} +[$p,$f]=memberFixture(); $f->pages=[[memberRow('990000000000000002')],[memberRow('990000000000000001')],[]]; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected' && count($f->pageCalls)===2,'Backward cursor accepted'); +[$p,$f]=memberFixture(); $f->pages=[[memberRow('990000000000000001')],[memberRow('990000000000000001')],[]]; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected' && count($f->pageCalls)===2,'Repeated cursor accepted'); +[$p,$f]=memberFixture(1); $f->pages=[[memberRow('990000000000000001')],[]]; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected' && count($f->pageCalls)===1,'Page ceiling treated as completion'); +[$p,$f]=memberFixture(3,0); $f->pages=[[memberRow('990000000000000001')],[]]; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected','Member ceiling treated as completion'); +[$p,$f]=memberFixture(); $f->pages=[array_fill(0,1001,memberRow('990000000000000001'))]; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected','Oversized page accepted'); +foreach ([null,['bad'],['880000000000000001','880000000000000001']] as $roles) { + [$p,$f]=memberFixture(); $f->roles=$roles; $f->pages=[[]]; + memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected' && $f->pageCalls===[],'Invalid initial role snapshot reached pagination'); +} +[$p,$f]=memberFixture(); $f->pages=[[]]; +$f->roleHook=static fn()=>$f->roleCalls===1?$f->roles:['880000000000000002']; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected','Changed final role set accepted'); +// Synchronous throws and promise rejections never manufacture empty snapshots. +foreach (['roles','page'] as $stage) { + foreach (['throw','reject'] as $mode) { + [$p,$f]=memberFixture(); $f->pages=[[]]; + $hook=static function () use ($mode): mixed { if ($mode==='throw') { throw new RuntimeException('synthetic'); } return reject(new RuntimeException('synthetic')); }; + if ($stage==='roles') { $f->roleHook=$hook; } else { $f->pageHook=$hook; } + memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected','Adapter failure accepted'); + $f->roleHook=null; $f->pageHook=null; $f->pages=[[]]; + memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='resolved','Settled failure left provider busy'); + } +} +foreach ([['connected'=>false],['epoch'=>2],['generation'=>8],['guild_id'=>'770000000000000002']] as $change) { + [$p,$f]=memberFixture(); $wait=new Deferred(); $f->pageHook=static fn()=>$wait->promise(); + $promise=$p->snapshot($guild,7); $f->life=array_replace($f->life,$change); $wait->resolve([]); + memberCheck(memberOutcome($promise)['state']==='rejected' && $f->roleCalls===1,'Changed lifecycle gave authority'); +} +[$p,$f]=memberFixture(); $f->life['connected']=false; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected' && $f->roleCalls===0 && $f->timerCalls===0,'Initially disconnected provider made requests'); +// Deadline rejects without cancelling or overlapping the outstanding fake transport. +[$p,$f]=memberFixture(); $wait=new Deferred(); $f->pageHook=static fn()=>$wait->promise(); +$promise=$p->snapshot($guild,7); +memberCheck(memberOutcome($promise)['state']==='pending','Deferred page settled early'); +$f->now=10; ($f->expire)(); +memberCheck(memberOutcome($promise)['state']==='rejected' && memberOutcome($wait->promise())['state']==='pending','Timeout pretended to cancel underlying request'); +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected' && $f->roleCalls===1,'Timeout allowed overlap while transport pending'); +$wait->resolve([memberRow('990000000000000001')]); +memberCheck(count($f->pageCalls)===1 && $f->roleCalls===1,'Late response issued more requests'); +$f->pageHook=null; $f->pages=[[]]; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='resolved','Drained timeout did not release busy gate'); +// Clock checks enforce the deadline even if a delayed timer has not fired. +foreach ([10.0,-1.0,INF,NAN] as $time) { + [$p,$f]=memberFixture(); $wait=new Deferred(); $f->pageHook=static fn()=>$wait->promise(); + $promise=$p->snapshot($guild,7); $f->now=$time; $wait->resolve([]); + memberCheck(memberOutcome($promise)['state']==='rejected','Expired/nonmonotonic clock accepted'); +} +// Deadline registration must succeed; a synchronous expiry must cause zero fetches. +[$p,$f]=memberFixture(); $f->deadlineHook=static fn()=>null; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected' && $f->roleCalls===0,'Missing deadline registration accepted'); +[$p,$f]=memberFixture(); +$f->deadlineHook=static function (callable $expire) use ($f): callable { $expire(); return static function () use ($f): void { ++$f->cancelCalls; }; }; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected' && $f->roleCalls===0 && $f->cancelCalls===1,'Synchronous deadline expiry fetched data'); +// Reentrant expiry inside an injected observer cannot start more transport work. +[$p,$f]=memberFixture(); +$f->observeHook=static function () use ($f): array { + if ($f->timerCalls===1) { $f->observeHook=null; ($f->expire)(); } + return $f->life; +}; +memberCheck(memberOutcome($p->snapshot($guild,7))['state']==='rejected' && $f->roleCalls===0,'Observer expiry still started roles request'); +[$p,$f]=memberFixture(); $wait=new Deferred(); $f->pageHook=static fn()=>$wait->promise(); +$promise=$p->snapshot($guild,7); +$f->observeHook=static function () use ($f): array { $f->observeHook=null; ($f->expire)(); return $f->life; }; +$wait->resolve([]); +memberCheck(memberOutcome($promise)['state']==='rejected' && $f->roleCalls===1,'Observer expiry still accepted terminal page'); +// Resolved subscriber can begin another request after busy is released. +[$p,$f]=memberFixture(); $wait=new Deferred(); +$f->pageHook=static fn()=>$wait->promise(); $promise=$p->snapshot($guild,7); $next=null; +$promise->then(static function () use ($p,$f,$guild,&$next): void { $f->pageHook=null; $f->pages=[[]]; $next=$p->snapshot($guild,7); }); +$wait->resolve([]); +memberCheck($next instanceof PromiseInterface && memberOutcome($next)['state']==='resolved','Resolved reentrancy kept provider busy'); +echo "PASS injected member provider: terminal pagination, bounded limits, string cursors, invalidation, deadline/drain and no partial authority\n"; diff --git a/tests/local/admin-sync-coordinator.php b/tests/local/admin-sync-coordinator.php new file mode 100644 index 00000000..071269de --- /dev/null +++ b/tests/local/admin-sync-coordinator.php @@ -0,0 +1,196 @@ +then(static function (array $value) use (&$result): void { $result = $value; }); + coordinatorCheck($result !== null, 'Expected settled result'); + return $result; +} +function coordinatorPending(PromiseInterface $promise): bool { + $settled = false; + $promise->then(static function () use (&$settled): void { $settled = true; }, static function () use (&$settled): void { $settled = true; }); + return !$settled; +} +function coordinatorFixture(bool $enabled = true, int $maxPasses = 3): array { + $f = (object)['now'=>1800000000,'calls'=>['refresh'=>0,'members'=>0,'project'=>0,'write'=>0], + 'refreshHook'=>null,'membersHook'=>null,'projectHook'=>null,'writeHook'=>null,'bytes'=>[],'guards'=>[]]; + $f->snapshot = ['status'=>['available'=>true,'stale'=>false,'has_valid_list'=>true,'refresh_in_progress'=>false, + 'records'=>1,'age_seconds'=>0,'last_success_utc'=>gmdate('Y-m-d\TH:i:s\Z',$f->now)], + 'records'=>[['discord'=>'990000000000000001','ss13'=>'fakealpha']]]; + $f->membership = ['guild_id'=>'770000000000000001','complete'=>true,'connected'=>true, + 'roles'=>['880000000000000001'],'members'=>[['id'=>'990000000000000001','roles'=>['880000000000000001']]]]; + $c = new AdminSyncCoordinator($enabled, + static function () use ($f): mixed { + ++$f->calls['refresh']; + return $f->refreshHook !== null ? ($f->refreshHook)() : $f->snapshot; + }, + static function (int $generation) use ($f): mixed { + ++$f->calls['members']; + return $f->membersHook !== null ? ($f->membersHook)($generation) : $f->membership + ['generation'=>$generation]; + }, + static function (array $snapshot, array $membership, int $generation) use ($f): mixed { + ++$f->calls['project']; + if ($f->projectHook !== null) { return ($f->projectHook)($snapshot,$membership,$generation); } + return AdminSyncSnapshotGate::render($snapshot['status'],$snapshot['records'],$membership, + '770000000000000001',$generation,$f->now,['FixtureAdmin'=>'880000000000000001'],['FixtureAdmin'=>['FixtureAdmin','3']]); + }, + static function (string $bytes, int $generation, Closure $current) use ($f): mixed { + ++$f->calls['write']; $f->guards[] = $current; + if ($f->writeHook !== null) { return ($f->writeHook)($bytes,$generation,$current); } + coordinatorCheck($current(), 'Default fake writer received stale work'); + $f->bytes[] = [$generation,$bytes]; + return ['state'=>'updated']; + }, $maxPasses); + return [$c,$f]; +} + +[$c,$f] = coordinatorFixture(false); +$r = coordinatorOutcome($c->request()); +coordinatorCheck($r['state']==='disabled' && $r['passes']===0 && $r['writes']===0 && array_sum($f->calls)===0,'Disabled gate had effects or claimed success'); +[$c,$f] = coordinatorFixture(); +$r = coordinatorOutcome($c->request()); +coordinatorCheck($r['state']==='updated' && $r['writes']===1 && $f->bytes===[[1,"fakealpha;FixtureAdmin;3|||\n"]],'Successful projection/writer outcome lost'); +coordinatorCheck(!($f->guards[0])(),'Finished generation remained writable'); +[$c,$f] = coordinatorFixture(); $f->writeHook = static fn()=>['state'=>'unchanged']; +$r = coordinatorOutcome($c->request()); +coordinatorCheck($r['state']==='unchanged' && $r['writes']===0 && $f->calls['write']===1,'Unchanged confused with update'); +[$c,$f] = coordinatorFixture(); $f->snapshot['records']=[]; $f->snapshot['status']['records']=0; +coordinatorCheck(coordinatorOutcome($c->request())['state']==='updated' && $f->bytes===[[1,'']],'Valid empty lost authoritative write'); +foreach (['throw','reject','unavailable','stale','missing'] as $mode) { + [$c,$f] = coordinatorFixture(); + $f->refreshHook = static function () use ($f,$mode): mixed { + if ($mode==='throw') { throw new RuntimeException('synthetic'); } + if ($mode==='reject') { return reject(new RuntimeException('synthetic')); } + if ($mode==='missing') { return []; } + $snapshot=$f->snapshot; $snapshot['records']=[]; $snapshot['status']['records']=0; + $snapshot['status'][$mode==='stale'?'stale':'available']=$mode==='stale'; + return $snapshot; + }; + $r=coordinatorOutcome($c->request()); + coordinatorCheck($r['state']==='blocked' && $r['phase']==='refresh' && $f->calls['members']===0 && $f->calls['write']===0,'Bad refresh reached members/writer'); + $f->refreshHook=null; + coordinatorCheck(coordinatorOutcome($c->request())['state']==='updated','Refresh failure left busy state stuck'); +} +foreach (['throw','reject','incomplete','wrong-generation'] as $mode) { + [$c,$f] = coordinatorFixture(); + $f->membersHook = static function (int $generation) use ($f,$mode): mixed { + if ($mode==='throw') { throw new RuntimeException('synthetic'); } + if ($mode==='reject') { return reject(new RuntimeException('synthetic')); } + return array_replace($f->membership,['generation'=>$mode==='wrong-generation'?$generation+1:$generation,'complete'=>$mode!=='incomplete']); + }; + coordinatorCheck(coordinatorOutcome($c->request())['state']==='blocked' && $f->calls['write']===0,'Invalid members reached writer'); +} +// Stale completions never reach a later stage. Three requests coalesce to two refreshes. +[$c,$f] = coordinatorFixture(); $refresh = new Deferred(); +$f->refreshHook = static fn()=>$f->calls['refresh']===1?$refresh->promise():$f->snapshot; +$p=$c->request(); coordinatorCheck(coordinatorPending($p),'Refresh unexpectedly settled'); +coordinatorCheck($c->request()===$p && $c->request()===$p,'Burst did not share pending promise'); +$refresh->resolve($f->snapshot); $r=coordinatorOutcome($p); +coordinatorCheck($r['generation']===3 && $r['passes']===2 && $f->calls['refresh']===2 && $f->calls['members']===1 && $f->bytes[0][0]===3,'Stale refresh was used or dirty triggers lost'); +[$c,$f] = coordinatorFixture(); $members=new Deferred(); +$f->membersHook=static fn(int $g)=>$f->calls['members']===1?$members->promise():$f->membership+['generation'=>$g]; +$p=$c->request(); $c->request(); $members->resolve($f->membership+['generation'=>1]); +coordinatorCheck(coordinatorOutcome($p)['state']==='updated' && $f->calls['project']===2 && $f->bytes[0][0]===2,'Stale membership reached projection'); +// Recheck freshness after asynchronous membership finishes. +[$c,$f] = coordinatorFixture(); $members=new Deferred(); $f->membersHook=static fn()=>$members->promise(); +$p=$c->request(); $f->now+=1800; $members->resolve($f->membership+['generation'=>1]); +coordinatorCheck(coordinatorOutcome($p)['state']==='blocked' && $f->calls['write']===0,'Expired verification reached writer'); +// A synchronous event from the projector invalidates before writer entry. +[$c,$f] = coordinatorFixture(); +$f->projectHook=static function () use ($c,$f): string { if ($f->calls['project']===1) { $c->request(); } return 'synthetic'; }; +coordinatorCheck(coordinatorOutcome($c->request())['writes']===1 && $f->bytes[0][0]===2,'Projection-time event allowed stale writer'); +// Do not overlap writers; delayed writer can observe invalidation before committing. +[$c,$f] = coordinatorFixture(); $write=new Deferred(); +$f->writeHook=static fn()=> $f->calls['write']===1?$write->promise():['state'=>'updated']; +$p=$c->request(); $c->request(); +coordinatorCheck($f->calls['refresh']===1 && !($f->guards[0])() && coordinatorPending($p),'Overlapped work or stale guard'); +$write->resolve(['state'=>'superseded']); $r=coordinatorOutcome($p); +coordinatorCheck($r['state']==='updated' && $r['passes']===2 && $r['writes']===1,'Dirty event during write was lost'); +// A delayed writer must also recheck freshness, not only generation. +[$c,$f] = coordinatorFixture(); $write=new Deferred(); $f->writeHook=static fn()=>$write->promise(); +$p=$c->request(); $f->now+=1800; +coordinatorCheck(!($f->guards[0])(), 'Expired snapshot still allowed commit'); +$write->resolve(['state'=>'superseded']); $r=coordinatorOutcome($p); +coordinatorCheck($r['state']==='superseded' && $r['retry_required'] && $r['writes']===0,'Expired pending writer claimed success'); +// Already-committed old generation must be reported, not pretended rolled back. +[$c,$f] = coordinatorFixture(); $write=new Deferred(); +$f->writeHook=static fn()=> $f->calls['write']===1?$write->promise():['state'=>'updated']; +$p=$c->request(); $c->request(); $write->resolve(['state'=>'updated']); $r=coordinatorOutcome($p); +coordinatorCheck($r['writes']===2 && $r['last_written_generation']===2,'Committed older write was hidden'); +// A known pre-mutation writer refusal is blocked, not integrity-uncertain. +[$c,$f] = coordinatorFixture(); $f->writeHook=static fn()=>['state'=>'blocked','reason'=>'lock-busy']; +$r=coordinatorOutcome($c->request()); +coordinatorCheck($r['state']==='blocked' && $r['phase']==='write' && $r['writes']===0 && !$r['integrity_uncertain'],'Known writer refusal lost no-mutation status'); +// A later blocked pass must expose an earlier committed update in the same burst. +[$c,$f] = coordinatorFixture(); $write=new Deferred(); $f->writeHook=static fn()=>$write->promise(); +$f->refreshHook=static function () use ($f): array { + $snapshot=$f->snapshot; + if ($f->calls['refresh']>1) { $snapshot['status']['available']=false; } + return $snapshot; +}; +$p=$c->request(); $c->request(); $write->resolve(['state'=>'updated']); $r=coordinatorOutcome($p); +coordinatorCheck($r['state']==='blocked' && $r['writes']===1 && $r['last_written_generation']===1 && $r['generation']===2,'Earlier committed write hidden by later block'); +// Precommit gate refuses changed bytes and exceptions, not only expiry. +foreach (['changed','throw'] as $mode) { + [$c,$f] = coordinatorFixture(); $write=new Deferred(); $f->writeHook=static fn()=>$write->promise(); + $p=$c->request(); + $f->projectHook=static function () use ($mode): string { + if ($mode==='throw') { throw new RuntimeException('synthetic precommit gate failure'); } + return 'changed'; + }; + coordinatorCheck(!($f->guards[0])(),'Changed/failed projection allowed commit'); + $write->resolve(['state'=>'superseded']); + coordinatorCheck(coordinatorOutcome($p)['writes']===0,'Precommit rejection reported a write'); +} +[$c,$f] = coordinatorFixture(); $write=new Deferred(); +$f->writeHook=static fn()=>$f->calls['write']===1?$write->promise():['state'=>'updated']; +$p=$c->request(); +$f->projectHook=static function () use ($c,$f): string { + $f->projectHook=null; $c->request(); return "fakealpha;FixtureAdmin;3|||\n"; +}; +coordinatorCheck(!($f->guards[0])(),'Precommit-time invalidation allowed commit'); +$write->resolve(['state'=>'superseded']); +coordinatorCheck(coordinatorOutcome($p)['last_written_generation']===2,'Precommit-time invalidation lost dirty pass'); +foreach (['throw','reject','invalid'] as $mode) { + [$c,$f] = coordinatorFixture(); + $f->writeHook=static function () use ($mode): mixed { + if ($mode==='throw') { throw new RuntimeException('synthetic partial write'); } + return $mode==='reject'?reject(new RuntimeException('synthetic partial write')):null; + }; + $r=coordinatorOutcome($c->request()); + coordinatorCheck($r['state']==='failed' && $r['integrity_uncertain'] && $r['writes']===0 && $r['passes']===1,'Writer failure misreported/retried'); +} +[$c,$f] = coordinatorFixture(); $write=new Deferred(); $f->writeHook=static fn()=>$write->promise(); +$p=$c->request(); $c->request(); $write->reject(new RuntimeException('synthetic partial write')); $r=coordinatorOutcome($p); +coordinatorCheck($r['state']==='failed' && $r['retry_required'] && $r['integrity_uncertain'] && $f->calls['refresh']===1,'Dirty writer failure was automatically retried'); +// Continuous churn is bounded and explicitly asks the owner for another reconciliation. +[$c,$f] = coordinatorFixture(true,2); +$f->refreshHook=static function () use ($c,$f): array { $c->request(); return $f->snapshot; }; +$r=coordinatorOutcome($c->request()); +coordinatorCheck($r['state']==='superseded' && $r['retry_required'] && $r['passes']===2 && $f->calls['write']===0,'Churn was unbounded or reported success'); +$f->refreshHook=null; +coordinatorCheck(coordinatorOutcome($c->request())['state']==='updated','Budget exhaustion kept busy state'); +// Terminal subscribers may synchronously start a new independent request. +[$c,$f] = coordinatorFixture(); $refresh=new Deferred(); $f->refreshHook=static fn()=>$f->calls['refresh']===1?$refresh->promise():$f->snapshot; +$p=$c->request(); $next=null; +$p->then(static function () use ($c,&$next): void { $next=$c->request(); }); +$refresh->resolve($f->snapshot); +coordinatorCheck($next instanceof PromiseInterface && coordinatorOutcome($next)['generation']===2 && $f->calls['write']===2,'Terminal reentrancy lost new request'); +echo "PASS injected coordinator: disabled, valid empty, failure, stale suppression, shared promise, bounded dirty passes, writer outcomes and reentrancy\n"; diff --git a/tests/local/admin-sync-projection.php b/tests/local/admin-sync-projection.php new file mode 100644 index 00000000..92745ac3 --- /dev/null +++ b/tests/local/admin-sync-projection.php @@ -0,0 +1,100 @@ +\\s*\\['([^']+)',\\s*'([0-9]+)'\\]/", $line, $parts)) { + $permissions[$parts[1]] = [$parts[2], $parts[3]]; + } +} +projectionCheck(count($permissions) === 6 && array_key_first($permissions) === 'Owner', 'Review changed permission layout'); +$roleIds = []; $i = 0; +foreach ($permissions as $name => $_) { $roleIds[$name] = '88000000000000000'.(++$i); } +$alpha = '990000000000000001'; $beta = '990000000000000002'; $guild = '770000000000000001'; +$verified = [['discord'=>$alpha,'ss13'=>'Fake.Alpha'],['discord'=>$beta,'ss13'=>'Fake Beta']]; +$members = [['id'=>$alpha,'roles'=>array_values($roleIds)],['id'=>$beta,'roles'=>[$roleIds['Admin']]]]; +$expected = "fakealpha;Host;65535|||\nfakebeta;Admin;16254|||\n"; +projectionCheck(AdminListRenderer::render($verified,$members,$roleIds,$permissions) === $expected, 'Precedence or LF framing changed'); +foreach ($permissions as $name => [$rank,$flags]) { + projectionCheck(AdminListRenderer::render([$verified[0]],[['id'=>$alpha,'roles'=>[$roleIds[$name]]]],$roleIds,$permissions) === "fakealpha;$rank;$flags|||\n", 'Rank mapping changed'); +} +projectionCheck(AdminListRenderer::render(array_reverse($verified),array_reverse($members),$roleIds,$permissions) === $expected, 'Output is not deterministic'); +projectionCheck(AdminListRenderer::render($verified,[['id'=>$beta,'roles'=>[$roleIds['Admin']]]],$roleIds,$permissions) === "fakebeta;Admin;16254|||\n", 'Departed member retained'); +projectionCheck(AdminListRenderer::render([$verified[0]],$members,$roleIds,$permissions) === "fakealpha;Host;65535|||\n", 'Unverified member included'); +projectionCheck(AdminListRenderer::render($verified,[['id'=>$alpha,'roles'=>[]]],$roleIds,$permissions) === '', 'Role removal retained entry'); +projectionCheck(AdminListRenderer::render([],$members,$roleIds,$permissions) === '', 'Empty verified list not authoritative'); +projectionCheck(AdminListRenderer::render($verified,[],$roleIds,$permissions) === '', 'Empty membership not authoritative'); +projectionRejects(fn()=>AdminListRenderer::render($verified,$members,[],$permissions),'Empty role map accepted'); +projectionRejects(fn()=>AdminListRenderer::render($verified,$members,$roleIds,[]),'Empty permissions accepted'); +projectionRejects(fn()=>AdminListRenderer::render([$verified[0],$verified[0]],$members,$roleIds,$permissions),'Duplicate verification accepted'); +projectionRejects(fn()=>AdminListRenderer::render([$verified[0],['discord'=>$beta,'ss13'=>'fakealpha']],$members,$roleIds,$permissions),'Ambiguous ckey accepted'); +projectionRejects(fn()=>AdminListRenderer::render($verified,[$members[0],$members[0]],$roleIds,$permissions),'Duplicate member accepted'); +foreach (["[badckey]", "bad;ckey", "bad|ckey", "bad\nckey", "bad\0ckey"] as $ckey) { + projectionRejects(fn()=>AdminListRenderer::render([['discord'=>$alpha,'ss13'=>$ckey]],$members,$roleIds,$permissions),'Unsafe ckey accepted'); +} +// Existing hosted normalization permits legacy bracketed spelling. Unrelated rows +// must not poison a valid admin projection; no real identities are used here. +$legacy = array_merge($verified, [ + ['discord'=>'990000000000000003','ss13'=>'[legacy-one]'], + ['discord'=>'990000000000000004','ss13'=>'[legacy-two]'], + ['discord'=>'990000000000000005','ss13'=>'[legacy-three]'], +]); +projectionCheck(AdminListRenderer::render($legacy,$members,$roleIds,$permissions) === $expected, 'Unrelated legacy spelling poisoned projection'); +$legacy[] = ['discord'=>'990000000000000006','ss13'=>'unrelated;legacy']; +projectionCheck(AdminListRenderer::render($legacy,$members,$roleIds,$permissions) === $expected, 'Unselected framing spelling poisoned projection'); +$nonAdminLegacy = array_merge($members,[['id'=>'990000000000000006','roles'=>[]]]); +projectionCheck(AdminListRenderer::render($legacy,$nonAdminLegacy,$roleIds,$permissions) === $expected, 'Non-admin legacy member poisoned projection'); +$badPermissions = $permissions; $badPermissions['Admin'] = ["Bad;Rank", '16254']; +projectionRejects(fn()=>AdminListRenderer::render($verified,$members,$roleIds,$badPermissions),'Unsafe rank accepted'); +$badPermissions['Admin'] = ['Admin', '65536']; +projectionRejects(fn()=>AdminListRenderer::render($verified,$members,$roleIds,$badPermissions),'Out-of-range flags accepted'); +$badRoles = $roleIds; $badRoles['Admin'] = $roleIds['Owner']; +projectionRejects(fn()=>AdminListRenderer::render($verified,$members,$badRoles,$permissions),'Aliased role configuration accepted'); + +$now = 1800000000; +$status = ['available'=>true,'stale'=>false,'has_valid_list'=>true,'refresh_in_progress'=>false,'records'=>2,'age_seconds'=>0,'last_success_utc'=>gmdate('Y-m-d\TH:i:s\Z',$now)]; +$membership = ['guild_id'=>$guild,'complete'=>true,'connected'=>true,'generation'=>7,'roles'=>array_values($roleIds),'members'=>$members]; +$render = static fn(array $s, array $v, array $m, int $generation=7, ?int $time=null): string => AdminSyncSnapshotGate::render($s,$v,$m,$guild,$generation,$time ?? $now,$roleIds,$permissions); +projectionCheck($render($status,$verified,$membership) === $expected,'Complete fresh snapshot rejected'); +$emptyStatus = array_replace($status,['records'=>0]); +projectionCheck($render($emptyStatus,[],$membership) === '', 'Valid empty verification blocked'); +projectionCheck($render($status,$verified,array_replace($membership,['members'=>[]])) === '', 'Valid empty guild blocked'); +foreach ([['available'=>false],['available'=>1],['stale'=>true],['has_valid_list'=>false],['refresh_in_progress'=>true],['records'=>0],['age_seconds'=>1800],['age_seconds'=>-1],['last_success_utc'=>'invalid'],['last_success_utc'=>gmdate('Y-m-d\TH:i:s\Z',$now+1)]] as $change) { + projectionRejects(fn()=>$render(array_replace($status,$change),$verified,$membership),'Unsafe verification status accepted'); +} +projectionRejects(fn()=>$render(array_replace($emptyStatus,['available'=>false]),[],$membership),'Unavailable initial empty snapshot treated as revocation'); +projectionRejects(fn()=>$render(array_replace($emptyStatus,['stale'=>true]),[],$membership),'Stale empty snapshot treated as revocation'); +projectionRejects(fn()=>$render($status,$verified,$membership,7,$now+1800),'Previously fresh status did not expire'); +foreach ([['complete'=>false],['complete'=>1],['connected'=>false],['guild_id'=>'770000000000000002'],['generation'=>6],['roles'=>[]],['members'=>null]] as $change) { + projectionRejects(fn()=>$render($status,$verified,array_replace($membership,$change)),'Unsafe membership accepted'); +} +projectionRejects(fn()=>$render($emptyStatus,[],array_replace($membership,['complete'=>false,'members'=>[]])),'Incomplete empty guild treated as revocation'); +projectionRejects(fn()=>$render($status,$verified,$membership,8),'Obsolete generation accepted'); +foreach (array_keys($status) as $key) { + $missing = $status; unset($missing[$key]); + projectionRejects(fn()=>$render($missing,$verified,$membership),'Missing verification gate field accepted'); +} +foreach (array_keys($membership) as $key) { + $missing = $membership; unset($missing[$key]); + projectionRejects(fn()=>$render($status,$verified,$missing),'Missing membership gate field accepted'); +} +echo "PASS pure admin projection: ordered ranks, LF, removals, valid empty, invalid/stale/incomplete rejection and generation gate\n"; diff --git a/tests/local/admin-verification-refresh.php b/tests/local/admin-verification-refresh.php new file mode 100644 index 00000000..00c7e6cc --- /dev/null +++ b/tests/local/admin-verification-refresh.php @@ -0,0 +1,156 @@ +then(static function (array $status) use (&$result): void { $result=$status; }); + if ($result===null) { throw new \RuntimeException('Expected completed fake refresh'); } + return $result; + } + final class RefreshObserver extends Civ13 + { + public array $notifications=[]; + public ?\Closure $notificationHook=null; + public function hostedVerificationRefreshed(Verifier $verifier): void { + $this->notifications[]=['verifier'=>$verifier,'records'=>iterator_to_array($verifier->verified,false), + 'available'=>$this->verifier_online]; + if ($this->notificationHook!==null) { ($this->notificationHook)(); } + } + } + final class RefreshLogger extends \Monolog\Logger + { + public array $infos=[]; + public array $warnings=[]; + public bool $throwWarning=false; + public function info(string|\Stringable $message,array $context=[]): void { + $this->infos[]=['message'=>(string)$message,'context'=>$context]; + } + public function warning(string|\Stringable $message,array $context=[]): void { + $this->warnings[]=['message'=>(string)$message,'context'=>$context]; + if ($this->throwWarning) { throw new \RuntimeException('Synthetic optional warning failure'); } + } + } + + $f=(object)['now'=>1800000000,'calls'=>0,'reply'=>['http_status'=>200,'json'=>'[]'],'pending'=>null]; + HostedVerificationSource::$fetch=static function () use ($f): PromiseInterface { + ++$f->calls; + return $f->pending?->promise() ?? resolve($f->reply); + }; + $owner=(new \ReflectionClass(RefreshObserver::class))->newInstanceWithoutConstructor(); + $owner->browser=(new \ReflectionClass(\React\Http\Browser::class))->newInstanceWithoutConstructor(); + $verifier=(new \ReflectionClass(Verifier::class))->newInstanceWithoutConstructor(); + $verifier->civ13=$owner; $owner->verifier=$verifier; + $verifier->logger=$logger=new RefreshLogger('offline-verifier'); + $verifier->verified=new Collection([], 'discord'); + $snapshot=new HostedVerificationSnapshot(static fn()=>$f->now); + (new \ReflectionProperty(Verifier::class,'hosted'))->setValue($verifier,$snapshot); + $records=[['discord'=>'990000000000000001','ss13'=>'fakealpha']]; + $f->reply=['http_status'=>200,'json'=>json_encode($records,JSON_THROW_ON_ERROR)]; + refreshResult($verifier->refreshHosted()); + refreshCheck($f->calls===1 && count($owner->notifications)===1,'Initial successful recovery did not notify once'); + refreshCheck($owner->notifications[0]['records']===$records && $owner->notifications[0]['available']===true, + 'Notification preceded successful verification-cache replacement'); + + refreshResult($verifier->refreshHosted()); + refreshCheck($f->calls===1 && count($owner->notifications)===1,'Coalesced refresh fetched or notified again'); + $f->now+=20; refreshResult($verifier->refreshHosted()); + refreshCheck($f->calls===2 && count($owner->notifications)===2,'Unchanged successful replacement lost its new snapshot timestamp notification'); + + $f->now+=20; $records[0]['ss13']='fakebeta'; $f->reply['json']=json_encode($records,JSON_THROW_ON_ERROR); + refreshResult($verifier->refreshHosted()); + refreshCheck($f->calls===3 && count($owner->notifications)===3 + && end($owner->notifications)['records']===$records,'Changed identity cache did not notify once'); + + $f->now+=20; $f->reply=['http_status'=>503,'json'=>'[]']; + $status=refreshResult($verifier->refreshHosted()); + refreshCheck($status['available']===false && count($owner->notifications)===3 + && iterator_to_array($verifier->verified,false)===$records,'Failed refresh notified or discarded retained identities'); + $f->now+=20; $f->reply=['http_status'=>200,'json'=>json_encode($records,JSON_THROW_ON_ERROR)]; + refreshResult($verifier->refreshHosted()); + refreshCheck($f->calls===5 && count($owner->notifications)===4,'Recovery with unchanged records did not notify'); + + $f->now+=20; $f->pending=new Deferred(); + $first=$verifier->refreshHosted(); $second=$verifier->refreshHosted(); + refreshCheck($f->calls===6 && count($owner->notifications)===4,'Pending refresh notified prematurely or overlapped fetches'); + $f->pending->resolve(['http_status'=>200,'json'=>'[]']); $f->pending=null; + refreshResult($first); $secondStatus=refreshResult($second); + refreshCheck($secondStatus['coalesced']===true && count($owner->notifications)===5 + && end($owner->notifications)['records']===[],'Overlapping completion duplicated notification or lost valid empty list'); + + $f->now+=20; $f->pending=new Deferred(); $failed=$verifier->refreshHosted(); + $f->pending->reject(new \RuntimeException('Synthetic source failure')); $f->pending=null; + refreshCheck(refreshResult($failed)['available']===false && count($owner->notifications)===5, + 'Rejected source notified synchronization'); + + // Optional notification failure, including its sanitized warning logger, cannot reject core success. + $owner->notificationHook=static function (): never { throw new \RuntimeException('Synthetic private identity/path detail'); }; + foreach ([false,true] as $throwWarning) { + $logger->throwWarning=$throwWarning; $f->now+=20; + $f->reply=['http_status'=>200,'json'=>json_encode($records,JSON_THROW_ON_ERROR)]; + $before=[$f->calls,count($owner->notifications),count($logger->infos),count($logger->warnings)]; + $status=refreshResult($verifier->refreshHosted()); + refreshCheck($status['available']===true && $owner->verifier_online===true + && iterator_to_array($verifier->verified,false)===$records && $snapshot->status()['available']===true, + 'Optional synchronization failure invalidated successful verifier cache/status'); + refreshCheck($f->calls===$before[0]+1 && count($owner->notifications)===$before[1]+1, + 'Optional notification failure retried the source or notification'); + refreshCheck(count($logger->infos)===$before[2]+1 + && end($logger->infos)===['message'=>'Hosted verification refresh','context'=>$status], + 'Optional notification/warning failure suppressed normal core logging'); + refreshCheck(count($logger->warnings)===$before[3]+1 + && end($logger->warnings)===['message'=>'Admin file synchronization notification failed','context'=>[]], + 'Optional failure warning exposed exception details or lacked sanitized feature signal'); + } + // Actual source failures remain unavailable; the optional catch is not around the source path. + $logger->throwWarning=false; $f->now+=20; $f->reply=['http_status'=>503,'json'=>'[]']; + $before=[$f->calls,count($owner->notifications),count($logger->warnings),count($logger->infos)]; + $status=refreshResult($verifier->refreshHosted()); + refreshCheck($status['available']===false && $owner->verifier_online===false + && iterator_to_array($verifier->verified,false)===$records,'Optional failure isolation masked a later source failure'); + refreshCheck($f->calls===$before[0]+1 && count($owner->notifications)===$before[1] + && count($logger->warnings)===$before[2] && count($logger->infos)===$before[3]+1, + 'Failed source invoked optional synchronization, retried, or lost its ordinary status log'); + $owner->notificationHook=null; + + // Real owner bridge is inert when default-disabled/unattached and does not autoload the feature. + $plain=(new \ReflectionClass(Civ13::class))->newInstanceWithoutConstructor(); + $plain->verifier=$verifier; $autoloads=0; + $trap=static function (string $class) use (&$autoloads): void { + if ($class==='Civ13\\AdminFileSync') { ++$autoloads; throw new \RuntimeException('Disabled feature autoload'); } + }; + spl_autoload_register($trap,true,true); + try { $plain->hostedVerificationRefreshed($verifier); } + finally { spl_autoload_unregister($trap); } + refreshCheck($autoloads===0 && !class_exists('Civ13\\AdminFileSync',false),'Disabled completion loaded optional implementation'); + + echo "PASS verifier completion hook ({$checks} assertions): actual replacement, changed/recovered success, failures, coalescing and disabled bridge\n"; +} diff --git a/tests/windows/bootstrap.php b/tests/windows/bootstrap.php index 9d55798a..7ae70568 100644 --- a/tests/windows/bootstrap.php +++ b/tests/windows/bootstrap.php @@ -20,6 +20,15 @@ function shell_exec(...$args): never { throw new \RuntimeException('Unexpected b final class BootstrapReached extends RuntimeException {} function check(bool $ok): void { if (!$ok) { throw new RuntimeException('Bootstrap fixture assertion failed'); } } $negative=in_array('--negative-control',$argv,true); + // Disabled admin sync must not require or autoload its optional implementation. + $adminSyncAutoloads=0; + $rejectAdminSync=static function(string $class) use (&$adminSyncAutoloads): void { + if ($class === 'Civ13\\AdminFileSync') { + ++$adminSyncAutoloads; + throw new RuntimeException('Disabled admin sync attempted autoload'); + } + }; + spl_autoload_register($rejectAdminSync); $root=sys_get_temp_dir().'/portable-bootstrap-'.bin2hex(random_bytes(8)); mkdir($root);mkdir($root.'/vendor');mkdir($root.'/src');mkdir($root.'/src/Civ13'); $previous=getcwd();$previousProfile=getenv('CIVILIZATIONBOT_PROFILE'); @@ -50,6 +59,8 @@ function check(bool $ok): void { if (!$ok) { throw new RuntimeException('Bootstr try { require $root.'/bot.php'; throw new RuntimeException('Bootstrap boundary not reached'); } catch(BootstrapReached){ check(!$negative && ($GLOBALS['bootstrap_guard_seen'] ?? false));$options=$GLOBALS['bootstrap_options']; + check($adminSyncAutoloads===0 && !class_exists('Civ13\\AdminFileSync',false)); + check($options['admin_file_sync']===['enabled'=>false]); check($options['http_whitelist']===['127.0.0.1'] && $options['http_port']===25565); check($options['channel_ids']===[] && $options['role_ids']===[] && $options['functions']===[]); check($options['http_key']==='' && $options['civ_token']==='' && $options['verify_url']===''); @@ -61,6 +72,7 @@ function check(bool $ok): void { if (!$ok) { throw new RuntimeException('Bootstr echo "PASS negative control detects original portable production-IP warning before options replacement\n"; } } finally { + spl_autoload_unregister($rejectAdminSync); restore_error_handler();chdir($previous); putenv($previousProfile===false?'CIVILIZATIONBOT_PROFILE':'CIVILIZATIONBOT_PROFILE='.$previousProfile); if(isset($portableLock)&&is_resource($portableLock)){fclose($portableLock);}