diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 5253626..bb8f0c4 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -11,38 +11,47 @@ on: jobs: phpforge: uses: infocyph/phpforge/.github/workflows/security-standards.yml@main + with: + integration_services: '[]' + service_topologies: '{}' + php_extensions: "curl, fileinfo, openssl" permissions: security-events: write actions: read contents: read - with: - php_versions: '["8.4","8.5"]' - dependency_versions: '["prefer-lowest","prefer-stable"]' - php_extensions: "curl, fileinfo, openssl" - composer_flags: "" - phpstan_memory_limit: "1G" - psalm_threads: "1" - run_analysis: true - run_svg_report: true - run_clean_install: true - benchmark_composer_script: "" - benchmark_result_file: "" - benchmark_baseline_file: "" - benchmark_max_regression_percent: 2 - benchmark_stable_environment: false - fail_on_skipped_tests: true - enable_redis_service: false - enable_valkey_service: false - enable_memcached_service: false - enable_postgres_service: false - enable_mysql_service: false - enable_scylladb_service: false - enable_elasticsearch_service: false - enable_mongodb_service: false - service_db_name: "phpforge" - service_db_user: "phpforge" - service_db_password: "phpforge" - artifact_retention_days: 61 + + optional-capability-coldness: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup minimal PHP runtime + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + extensions: "curl, fileinfo, openssl, :grpc, :imap, :posix" + coverage: none + + - name: Verify unloadable optional extensions are absent + run: | + php -r ' + foreach (["grpc", "imap", "posix"] as $extension) { + if (extension_loaded($extension)) { + fwrite(STDERR, "Optional extension unexpectedly loaded: {$extension}\n"); + exit(1); + } + } + ' + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Run optional capability coldness gate + env: + TALKINGBYTES_OPTIONAL_COLDNESS: "1" + run: vendor/bin/pest --configuration=vendor/infocyph/phpforge/resources/pest.xml --bootstrap=vendor/autoload.php tests/OptionalCapabilityColdnessTest.php mailpit-integration: runs-on: ubuntu-latest @@ -55,21 +64,43 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.5' + php-version: "8.5" extensions: curl, fileinfo, openssl + coverage: none - name: Install dependencies - run: composer install --no-interaction --prefer-dist + run: composer install --no-interaction --prefer-dist --no-progress - name: Run Mailpit integration test env: - RUN_MAILPIT_INTEGRATION: '1' + RUN_MAILPIT_INTEGRATION: "1" SMTP_HOST: 127.0.0.1 SMTP_PORT: 1025 MAILPIT_API_BASE: http://127.0.0.1:8025 run: vendor/bin/pest --configuration=vendor/infocyph/phpforge/resources/pest.xml --bootstrap=vendor/autoload.php --fail-on-skipped tests/MailpitIntegrationTest.php + + docs-warning-free: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: docs/requirements.txt + + - name: Install documentation dependencies + run: python -m pip install --disable-pip-version-check -r docs/requirements.txt + + - name: Build documentation with warnings as errors + run: sphinx-build -W --keep-going -b html docs build/docs + diff --git a/.gitignore b/.gitignore index 6991d72..cf24d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ test.php var vendor d2utmp* +/graphify-out diff --git a/benchmarks/EmailBench.php b/benchmarks/EmailBench.php index a53dfa8..d744703 100644 --- a/benchmarks/EmailBench.php +++ b/benchmarks/EmailBench.php @@ -4,7 +4,9 @@ namespace Infocyph\TalkingBytes\Benchmarks; +use Infocyph\TalkingBytes\Email\Emailer; use Infocyph\TalkingBytes\Email\EmailMessage; +use Infocyph\TalkingBytes\Email\Mailbox\FakeMailboxTransport; use Infocyph\TalkingBytes\Email\Parser\RawEmailParser; use Infocyph\TalkingBytes\Email\System\RawEmailBuilder; use PhpBench\Attributes\BeforeMethods; @@ -16,8 +18,14 @@ final class EmailBench { private RawEmailBuilder $builder; + private Emailer $fakeEmailer; + + private FakeMailboxTransport $mailbox; + private EmailMessage $message; + private Emailer $nullEmailer; + private RawEmailParser $parser; private string $rawMultipartEmail; @@ -26,6 +34,8 @@ public function setUp(): void { $this->builder = new RawEmailBuilder(); $this->parser = new RawEmailParser(); + $this->nullEmailer = Emailer::usingNull(); + $this->fakeEmailer = Emailer::fake(); $this->message = EmailMessage::new() ->from('sender@example.com', 'Sender Name') ->to('alice@example.com', 'bob@example.com') @@ -37,6 +47,7 @@ public function setUp(): void ->attachData(str_repeat('PDF-DATA-', 64), 'report.pdf', 'application/pdf') ->attachInlineData('', 'logo.svg', 'logo-inline', 'image/svg+xml'); $this->rawMultipartEmail = $this->createRawMultipartEmail(); + $this->mailbox = (new FakeMailboxTransport())->withMessage('INBOX', 1, $this->rawMultipartEmail); } #[Iterations(5)] @@ -58,6 +69,29 @@ static function (string $chunk): void { ); } + #[Iterations(5)] + #[Revs(100)] + public function benchFakeMailboxAdapter(): void + { + $this->mailbox->rawMessage('INBOX', 1); + $this->mailbox->rawHeaders('INBOX', 1); + $this->mailbox->status('INBOX'); + } + + #[Iterations(5)] + #[Revs(100)] + public function benchFakeSend(): void + { + $this->fakeEmailer->send($this->message); + } + + #[Iterations(5)] + #[Revs(500)] + public function benchNullSend(): void + { + $this->nullEmailer->send($this->message); + } + #[Iterations(5)] #[Revs(50)] public function benchParseMultipartEmail(): void diff --git a/benchmarks/GrpcBench.php b/benchmarks/GrpcBench.php index 6e1baf4..a8494ad 100644 --- a/benchmarks/GrpcBench.php +++ b/benchmarks/GrpcBench.php @@ -4,13 +4,120 @@ namespace Infocyph\TalkingBytes\Benchmarks; +use Infocyph\TalkingBytes\Grpc\GrpcClient; +use Infocyph\TalkingBytes\Grpc\GrpcInboundDispatcher; use Infocyph\TalkingBytes\Grpc\GrpcMetadata; +use Infocyph\TalkingBytes\Grpc\GrpcStatus; +use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundRequest; +use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundResponse; +use Infocyph\TalkingBytes\Grpc\Retry\GrpcRetryPolicy; use Infocyph\TalkingBytes\Grpc\Sender\GrpcRequest; +use Infocyph\TalkingBytes\Grpc\Sender\GrpcResponse; +use Infocyph\TalkingBytes\Grpc\Testing\FakeGrpcInboundSource; +use PhpBench\Attributes\BeforeMethods; use PhpBench\Attributes\Iterations; use PhpBench\Attributes\Revs; +#[BeforeMethods('setUp')] final class GrpcBench { + private GrpcClient $client; + + private GrpcInboundDispatcher $dispatcher; + + private GrpcClient $generatedClient; + + private GrpcInboundRequest $inboundRequest; + + private GrpcRequest $request; + + private GrpcClient $retryClient; + + public function setUp(): void + { + $caller = static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse( + GrpcStatus::Ok, + $request->message, + ); + + $this->client = GrpcClient::using($caller); + $this->retryClient = GrpcClient::using($caller) + ->withGrpcRetry(GrpcRetryPolicy::standard(attempts: 2, baseDelayMs: 0)); + + $stub = new class { + public function GetOrder(mixed $message, array $metadata = [], array $options = []): object + { + unset($metadata, $options); + + $call = new class { + public mixed $message = null; + + public function wait(): array + { + return [$this->message, (object) ['code' => GrpcStatus::Ok->value]]; + } + + public function getMetadata(): array + { + return []; + } + + public function getTrailingMetadata(): array + { + return []; + } + }; + $call->message = $message; + + return $call; + } + }; + + $this->generatedClient = GrpcClient::usingGeneratedStub( + $stub, + ['/orders.v1.OrderService/GetOrder' => 'GetOrder'], + ); + + $this->dispatcher = new GrpcInboundDispatcher([ + '/orders.v1.OrderService/GetOrder' => static function (GrpcInboundRequest $request): GrpcInboundResponse { + return GrpcInboundResponse::ok($request->message); + }, + ]); + $this->request = new GrpcRequest( + '/orders.v1.OrderService/GetOrder', + ['id' => 'order-1'], + new GrpcMetadata(['x-request-id' => ['bench-1']]), + 1.5, + ); + $this->inboundRequest = new GrpcInboundRequest( + '/orders.v1.OrderService/GetOrder', + ['id' => 'order-1'], + ); + } + + #[Iterations(5)] + #[Revs(500)] + public function benchGeneratedStubInvocation(): void + { + $this->generatedClient->send($this->request); + } + + #[Iterations(5)] + #[Revs(1000)] + public function benchHostAcceptedExchangeBridge(): void + { + $source = new FakeGrpcInboundSource(); + $source->enqueue($this->inboundRequest); + $this->dispatcher->serveOne($source); + } + + #[Iterations(5)] + #[Revs(1000)] + public function benchInboundDispatch(): void + { + $this->dispatcher->handle($this->inboundRequest); + } + #[Iterations(5)] #[Revs(1000)] public function benchRequestAndMetadata(): void @@ -22,4 +129,18 @@ public function benchRequestAndMetadata(): void 1.5, ); } + + #[Iterations(5)] + #[Revs(1000)] + public function benchRetryMiddlewareSuccessPath(): void + { + $this->retryClient->send($this->request); + } + + #[Iterations(5)] + #[Revs(1000)] + public function benchUnaryDispatch(): void + { + $this->client->send($this->request); + } } diff --git a/benchmarks/HttpBench.php b/benchmarks/HttpBench.php index 9edb0b5..db46dbf 100644 --- a/benchmarks/HttpBench.php +++ b/benchmarks/HttpBench.php @@ -8,10 +8,13 @@ use Infocyph\TalkingBytes\Core\Result\CommunicationResult; use Infocyph\TalkingBytes\Http\Contract\HttpMiddleware; use Infocyph\TalkingBytes\Http\Contract\HttpTransport; +use Infocyph\TalkingBytes\Http\Cookie\CookieJar; use Infocyph\TalkingBytes\Http\HttpClient; +use Infocyph\TalkingBytes\Http\HttpClientFactory; use Infocyph\TalkingBytes\Http\HttpRequest; use Infocyph\TalkingBytes\Http\Support\HeaderBag; use Infocyph\TalkingBytes\Http\Support\QueryParams; +use Infocyph\TalkingBytes\Http\Testing\FakeHttpTransport; use PhpBench\Attributes\BeforeMethods; use PhpBench\Attributes\Iterations; use PhpBench\Attributes\Revs; @@ -21,8 +24,15 @@ final class HttpBench { private HttpClient $client; + private HttpClient $cookieClient; + + private HttpClient $fakeClient; + private HttpRequest $request; + /** @var array */ + private array $resolvedConfig; + public function setUp(): void { $middleware = new class implements HttpMiddleware { @@ -41,6 +51,19 @@ public function send(HttpRequest $request): CommunicationResult ->withMiddleware($middleware) ->withMiddleware($middleware) ->withMiddleware($middleware); + $this->fakeClient = HttpClient::using(new FakeHttpTransport()); + $this->cookieClient = HttpClient::using(new FakeHttpTransport()) + ->withCookieJar(new CookieJar()); + $this->resolvedConfig = [ + 'timeoutSeconds' => 5, + 'defaultHeaders' => ['Accept' => 'application/json'], + 'auth' => ['driver' => 'bearer', 'token' => 'bench-token'], + 'cookies' => ['enabled' => true], + 'retry' => ['enabled' => true, 'attempts' => 2, 'base_delay_ms' => 0], + 'rate_limit' => ['enabled' => true, 'max_requests' => 100000, 'per_seconds' => 1], + 'circuit_breaker' => ['enabled' => true, 'failure_threshold' => 5, 'cool_down_seconds' => 30], + 'idempotency' => ['enabled' => true, 'header' => 'Idempotency-Key'], + ]; $this->request = HttpRequest::post('https://api.example.com/v1/orders?existing=1#frag') ->header('X-App', 'TalkingBytes') ->header('X-Trace', 'bench-123') @@ -78,6 +101,20 @@ public function benchBuildUrl(): void $this->request->buildUrl(); } + #[Iterations(5)] + #[Revs(500)] + public function benchCookieEnabledFakeSend(): void + { + $this->cookieClient->send($this->request); + } + + #[Iterations(5)] + #[Revs(1000)] + public function benchFakeTransportSend(): void + { + $this->fakeClient->send($this->request); + } + #[Iterations(5)] #[Revs(1000)] public function benchHeaderBag(): void @@ -85,6 +122,13 @@ public function benchHeaderBag(): void new HeaderBag(['Accept' => 'application/json', 'X-Trace' => ['one', 'two']]); } + #[Iterations(5)] + #[Revs(1000)] + public function benchImmutableClientConstruction(): void + { + HttpClient::using(new FakeHttpTransport()); + } + #[Iterations(5)] #[Revs(1000)] public function benchMiddlewarePipeline(): void @@ -98,4 +142,18 @@ public function benchOrderedQueryParams(): void { new QueryParams(['a' => [1, 2], 'active' => true]); } + + #[Iterations(5)] + #[Revs(1000)] + public function benchPrepareRequest(): void + { + $this->request->prepareForTransport(); + } + + #[Iterations(5)] + #[Revs(250)] + public function benchResolvedFactoryConstruction(): void + { + (new HttpClientFactory())->fromArray($this->resolvedConfig, new FakeHttpTransport()); + } } diff --git a/benchmarks/WebhookBench.php b/benchmarks/WebhookBench.php index b020224..09dc581 100644 --- a/benchmarks/WebhookBench.php +++ b/benchmarks/WebhookBench.php @@ -4,7 +4,6 @@ namespace Infocyph\TalkingBytes\Benchmarks; -use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus; use Infocyph\TalkingBytes\Webhook\Model\WebhookSignature; use Infocyph\TalkingBytes\Webhook\Replay\InMemoryWebhookReplayStore; use Infocyph\TalkingBytes\Webhook\Signing\WebhookSignatureParser; @@ -20,8 +19,12 @@ final class WebhookBench private string $payload; + private int $replayCounter = 0; + private InMemoryWebhookReplayStore $replayStore; + private WebhookSignature $signature; + private string $signatureHeader; private int $timestamp; @@ -30,8 +33,6 @@ final class WebhookBench public function setUp(): void { - CommunicationEventBus::listen(null); - $this->payload = json_encode([ 'id' => 1001, 'event' => 'invoice.paid', @@ -45,30 +46,56 @@ public function setUp(): void ], ], JSON_THROW_ON_ERROR); $this->timestamp = 1_720_000_000; - $this->signatureHeader = (new WebhookSignature('secret'))->buildHeader($this->payload, $this->timestamp); + $this->signature = new WebhookSignature('secret'); + $this->signatureHeader = $this->signature->buildHeader($this->payload, $this->timestamp, 'invoice.paid', 'delivery-bench'); $this->verifier = new WebhookVerifier(['current-secret', 'secret'], 300); $this->parser = new WebhookSignatureParser(); $this->replayStore = new InMemoryWebhookReplayStore(10_000); + $this->replayStore->claim('bench', 'duplicate', 60); + } + + #[Iterations(5)] + #[Revs(1000)] + public function benchDuplicateRejection(): void + { + $this->replayStore->claim('bench', 'duplicate', 60); } #[Iterations(5)] #[Revs(1000)] public function benchParseSignature(): void { - $this->parser->parse($this->signatureHeader); + $this->parser->parse($this->signatureHeader, version: 'v2'); } #[Iterations(5)] #[Revs(1000)] public function benchReplayClaim(): void { - $this->replayStore->claim('bench', bin2hex(random_bytes(8)), 60); + $this->replayCounter++; + $this->replayStore->claim('bench', 'delivery-' . $this->replayCounter, 60); + } + + #[Iterations(5)] + #[Revs(1000)] + public function benchSignWebhook(): void + { + $this->signature->buildHeader($this->payload, $this->timestamp, 'invoice.paid', 'delivery-bench'); + } + + #[Iterations(5)] + #[Revs(1000)] + public function benchVerificationAndReplayClaim(): void + { + $this->verifier->verifyResult($this->payload, $this->signatureHeader, now: $this->timestamp, event: 'invoice.paid', deliveryId: 'delivery-bench'); + $this->replayCounter++; + $this->replayStore->claim('verify', 'delivery-' . $this->replayCounter, 60); } #[Iterations(5)] #[Revs(1000)] public function benchVerifyWebhook(): void { - $this->verifier->verify($this->payload, $this->signatureHeader, $this->timestamp); + $this->verifier->verifyResult($this->payload, $this->signatureHeader, now: $this->timestamp, event: 'invoice.paid', deliveryId: 'delivery-bench'); } } diff --git a/captainhook.json b/captainhook.json deleted file mode 100644 index 782a292..0000000 --- a/captainhook.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "commit-msg": { - "enabled": false, - "actions": [] - }, - "pre-push": { - "enabled": false, - "actions": [] - }, - "pre-commit": { - "enabled": true, - "actions": [ - { - "action": "composer validate --strict", - "options": [] - }, - { - "action": "composer normalize --dry-run", - "options": [] - }, - { - "action": "composer ic:release:audit", - "options": [] - }, - { - "action": "composer ic:ci", - "options": [] - } - ] - }, - "prepare-commit-msg": { - "enabled": false, - "actions": [] - }, - "post-commit": { - "enabled": false, - "actions": [] - }, - "post-merge": { - "enabled": false, - "actions": [] - }, - "post-checkout": { - "enabled": false, - "actions": [] - }, - "post-rewrite": { - "enabled": false, - "actions": [] - }, - "post-change": { - "enabled": false, - "actions": [] - } -} diff --git a/composer.json b/composer.json index 0e8c5bd..3ed5b88 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "ext-iconv": "Optional fallback for inbound charset conversion when mbstring is unavailable.", "ext-imap": "Optional for imap_* address parsing and UTF7-IMAP fallback helpers.", "ext-mbstring": "Recommended for robust charset conversion and encoded header handling.", + "ext-posix": "Optional best-effort Unix sendmail process-group termination for descendant cleanup.", "ext-sodium": "Required for Ed25519-SHA256 DKIM signing and verification.", "grpc/grpc": "Required for generated PHP gRPC clients." }, diff --git a/docs/architecture.rst b/docs/architecture.rst index edeb5e2..37d1a94 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -30,6 +30,53 @@ Examples: Sensitive values are redacted before dispatch. +Runtime ownership and lifetime +------------------------------ + +TalkingBytes objects are safe to reuse only according to the state they own. + +- immutable request/configuration objects may be reused. +- an ``HttpClient`` without mutable collaborators is an immutable reusable + graph. +- ``CookieJar`` owns mutable session state and should be scoped to the + intended request/session lifetime. +- ``CircuitBreaker`` and ``RateLimiter`` own resilience state; sharing + them is an explicit host policy decision. +- mailbox/socket transports own connection/session state and should remain + execution- or worker-owned rather than globally shared. +- generated/native gRPC invokers inherit the lifetime of their channel/stub and + should be scoped deliberately by the host. +- fakes and spies own mutable test history and should be test-scoped. + +No global registry is introduced for cookies, resilience state, mailbox +connections, native clients, cancellation, or protocol events. + +Cancellation and host control +----------------------------- + +Long-running protocol work accepts the small ``CancellationSignal`` boundary +where interruption is useful. TalkingBytes checks that signal around retries, +bounded waits, stream progress, concurrent HTTP scheduling, mailbox watches, +sendmail process supervision, and accepted inbound gRPC exchanges. + +The host remains responsible for translating its own stop token, heartbeat, +release generation, or worker lifecycle into that signal. TalkingBytes does not +own worker supervision or process-global signal handling. + +Optional capability coldness +---------------------------- + +Optional protocol capabilities remain cold until selected. HTTP, webhook, and +basic email graphs must not initialize gRPC, IMAP, POSIX, PCNTL, or Sodium +capabilities. RSA DKIM is OpenSSL-backed; Sodium is required only by Ed25519 +DKIM. POSIX sendmail hardening is opportunistic and PCNTL is not part of the +normal runtime graph. + +The security workflow contains a minimal-extension coldness gate that exercises +these boundaries with unloadable gRPC, IMAP, and POSIX extensions disabled. It +also enforces source-level confinement for compiled-in PCNTL and Sodium +capabilities. + Module boundaries ----------------- @@ -48,3 +95,5 @@ Every module includes fakes/assertion helpers and fake protocol servers where us - gRPC: fake caller + retry tests. - Webhook: signature/replay/redaction tests. - Email: SMTP/IMAP/POP3/parser/bounce/authentication tests. +- runtime: sequential/Fiber isolation, cancellation, and optional-capability + coldness gates. diff --git a/docs/email/outbound.rst b/docs/email/outbound.rst index 6fa9bcf..a5dc7e9 100644 --- a/docs/email/outbound.rst +++ b/docs/email/outbound.rst @@ -83,7 +83,7 @@ Important ``SmtpConfig`` options: - ``authMechanism``: ``Auto``, ``Plain``, ``Login``, ``None`` - ``utf8Policy``: ``Reject``, ``Auto``, ``Require`` - ``allowEightBitMime`` -- ``captureTranscript`` (debug metadata) +- ``captureTranscript`` (explicit diagnostic metadata; disabled by default) - ``maxMessageBytes`` Per-recipient status @@ -102,3 +102,45 @@ Behavior notes - BCC recipients are part of envelope RCPT flow and are not written into message headers. - Outbound builder normalizes line endings to CRLF. - Streaming paths are used for large payload handling in SMTP/sendmail/spool transports. + +Sendmail process lifecycle +-------------------------- + +Sendmail execution uses an argument-array ``proc_open()`` path; no shell command +string is constructed. The process lifetime is bounded by monotonic timeout and +may receive a ``CancellationSignal`` through ``usingSendmail()``. + +TalkingBytes first requests graceful termination, waits a bounded grace period, +then forces termination if required. On Unix, when the optional POSIX functions +can successfully place the child into its own process group, termination targets +that group so descendants are cleaned up as well. If process-group isolation is +unavailable or cannot be established, TalkingBytes safely falls back to direct +child termination. + +``ext-pcntl`` is not required and normal email transports do not install signal +handlers. A host runtime should translate its own stop/signal policy into a +``CancellationSignal``. + +Persistent mailbox ownership +---------------------------- + +IMAP and POP3 mailbox objects own their connection/session state. Reuse one +instance only within the intended execution/worker scope. Long-running runtimes +should call ``connect()`` explicitly when eager connection is useful and +``logout()`` during deterministic scope cleanup; destructors remain best-effort +shutdown protection. + +For watch loops, ``watchUntilCancelled()`` adapts the shared +``CancellationSignal`` while the existing callable stop hook remains available. + +SMTP transcript diagnostics +--------------------------- + +``captureTranscript`` is an explicit opt-in debugging surface, not default +observability. Client authentication payloads are replaced with ``[REDACTED]`` +and message DATA bodies are represented only by byte count. + +The transcript can still contain envelope addresses, non-authentication SMTP +commands, capability text, and server response text. Treat it as sensitive +diagnostic data, retain it only when necessary, and do not enable it as routine +production logging. diff --git a/docs/email/testing.rst b/docs/email/testing.rst index cbbf24f..8735265 100644 --- a/docs/email/testing.rst +++ b/docs/email/testing.rst @@ -56,6 +56,10 @@ Spool tests include: Event assertions ---------------- -Attach a listener via ``Email::events($listener)`` and assert event payload -shape for ``email.send.*``, ``email.receive.*``, ``email.parse.failed``, -``mailbox.command.*``, and ``bounce.detected``. +Inject a ``CallableEventDispatcher`` into the sender/receiver/mailbox factory +or parser under test and assert payload shape for ``email.send.*``, +``email.receive.*``, ``email.parse.failed``, ``mailbox.command.*``, and +``bounce.detected``. + +``Email::events()`` remains an explicit compatibility facade; normal runtime +graphs do not read process-global listener state. diff --git a/docs/events.rst b/docs/events.rst index 276dfe1..fefbfb7 100644 --- a/docs/events.rst +++ b/docs/events.rst @@ -24,6 +24,16 @@ Inject a dispatcher $client = HttpClient::curl($events); +Email factories accept the same injected dispatcher: + +.. code-block:: php + + use Infocyph\TalkingBytes\Email\Email; + + $sender = Email::sender($events)->usingNull(); + $receiver = Email::receiver($events); + $mailbox = Email::mailbox($events); + Compatibility adapter --------------------- @@ -31,8 +41,9 @@ Compatibility adapter \Infocyph\TalkingBytes\Core\Event\CommunicationEventBus::listen($listener); -The static bus is retained for compatibility. Prefer constructor/factory -injection in long-running workers and tests to avoid global state leakage. +The static bus is retained only as an explicit compatibility facade. Normal +protocol graphs do not consult it. Prefer constructor/factory injection in all +new code, especially long-running workers and Fiber-based runtimes. Event families -------------- diff --git a/docs/extensions.rst b/docs/extensions.rst index 7588730..131ac18 100644 --- a/docs/extensions.rst +++ b/docs/extensions.rst @@ -16,10 +16,42 @@ Suggested extensions/packages - ``ext-grpc`` for native gRPC transport - ``grpc/grpc`` for generated PHP gRPC clients - ``ext-mbstring`` for robust charset conversion +- ``ext-posix`` for best-effort Unix sendmail process-group cleanup - ``ext-iconv`` as charset fallback when mbstring is unavailable - ``ext-imap`` for optional address parsing and UTF7-IMAP fallback helpers +- ``ext-sodium`` only for Ed25519-SHA256 DKIM signing and verification + +``ext-pcntl`` is deliberately neither required nor suggested. TalkingBytes does +not install signal handlers or use fork-based protocol concurrency. Worker and +supervisor signal ownership belongs to the host runtime. + +Cold capability behavior +------------------------ + +Optional capabilities are selected lazily. + +- HTTP and webhook graphs do not require or initialize native gRPC packages. +- basic outbound email and SMTP composition do not require IMAP, POSIX, PCNTL, + or Sodium. +- RSA-SHA256 DKIM uses OpenSSL and does not require Sodium. +- Ed25519-SHA256 DKIM checks for Sodium only when that algorithm is selected and + fails with a clear runtime error when it is unavailable. +- generated/native gRPC adapters are not probed by unrelated HTTP, webhook, or + email graphs. +- IMAP helper fallbacks are evaluated only by mailbox code that needs them. +- POSIX sendmail process-group hardening is opportunistic; the portable direct + child termination path remains available when POSIX functions are absent. + +The CI optional-capability coldness gate runs with unloadable ``ext-grpc``, +``ext-imap``, and ``ext-posix`` disabled and exercises unrelated protocol +graphs. Hosted PHP builds may compile PCNTL or Sodium in; the same gate therefore +also enforces that no runtime PCNTL calls exist and that Sodium calls remain +confined to the Ed25519 DKIM implementation. Fallback behavior ----------------- -Some parser/mailbox helpers degrade gracefully when optional extensions are unavailable. Check test skips in CI logs to detect extension-limited environments. +Parser, mailbox, and process helpers degrade gracefully when optional extensions +are unavailable. Optional capability failure must remain local to the feature +that was explicitly selected; importing or constructing an unrelated protocol +graph must not trigger extension or package probes. diff --git a/docs/grpc/inbound-outbound.rst b/docs/grpc/inbound-outbound.rst index b081e17..558d9d6 100644 --- a/docs/grpc/inbound-outbound.rst +++ b/docs/grpc/inbound-outbound.rst @@ -8,6 +8,7 @@ TalkingBytes now supports both directions: - outbound client calls via ``GrpcClient`` - inbound request dispatch via ``GrpcInboundDispatcher`` +- host-controlled one-exchange execution via ``GrpcInboundSource`` and ``GrpcInboundExchange`` Module layout ------------- @@ -15,7 +16,7 @@ Module layout - ``src/Grpc/GrpcClient.php`` outbound entrypoint - ``src/Grpc/Sender/*`` outbound request/response/transport models - ``src/Grpc/GrpcInboundDispatcher.php`` inbound entrypoint -- ``src/Grpc/Receiver/*`` inbound request/response/handler models +- ``src/Grpc/Receiver/*`` inbound request/response/handler/source/exchange models Outbound (Node A -> Node B) --------------------------- @@ -99,11 +100,52 @@ Inbound (Node B request handling) $response = $server->receive('/orders.v1.OrderService/Create', ['order_id' => 1001]); +Host-controlled inbound runtime +------------------------------- + +TalkingBytes intentionally does not own a forever-running server loop. A host such +as Foundation can provide a ``GrpcInboundSource`` and call ``serveOne()`` +inside its existing worker lifecycle. + +.. code-block:: php + + use Infocyph\TalkingBytes\Core\Support\CancellationSignal; + use Infocyph\TalkingBytes\Grpc\GrpcInboundDispatcher; + + $dispatcher = GrpcInboundDispatcher::new() + ->withHandler('/orders.v1.OrderService/Create', $handler); + + $served = $dispatcher->serveOne( + $source, + CancellationSignal::fromCallable($hostShouldStop), + ); + +The source owns waiting for/accepting one native exchange. The accepted exchange +exposes a normalized ``GrpcInboundRequest`` and receives exactly one +``GrpcInboundResponse``. TalkingBytes keeps dispatch/status/error semantics; +the host keeps worker heartbeat, restart, release-generation and process policy. + +Inbound streaming scope in 2.1 +------------------------------ + +The host-controlled inbound boundary in 2.1 is deliberately request/response: +one accepted ``GrpcInboundExchange`` exposes one normalized +``GrpcInboundRequest`` and is completed with exactly one +``GrpcInboundResponse``. + +TalkingBytes 2.1 does **not** expose a server-side inbound streaming exchange +contract. Client/server/bidirectional streaming support described in +:doc:`streaming` belongs to the outbound/native client adapter boundary and +processes iterables and callbacks incrementally without accumulating a complete +stream in memory. A future inbound streaming contract, if added, must preserve +the same incremental/bounded rule rather than buffering an entire stream. + Method dispatch behavior ------------------------ - unknown methods return ``GrpcStatus::Unimplemented`` -- handler exceptions return ``GrpcStatus::Internal`` +- handler exceptions return ``GrpcStatus::Internal`` with a stable public message +- handler exception classes/messages/traces never cross the response boundary by default - method and deadline are validated on inbound request construction Inbound events @@ -113,4 +155,6 @@ Inbound events - ``grpc.inbound.finish`` - ``grpc.inbound.failed`` -This makes inbound request processing observable with the same shared event bus. +Inbound observability is emitted through the injected dispatcher. Handler +exception classes may appear in local failed-event diagnostics, but not in the +wire response. diff --git a/docs/grpc/native.rst b/docs/grpc/native.rst index 93e3d43..4f5470b 100644 --- a/docs/grpc/native.rst +++ b/docs/grpc/native.rst @@ -32,19 +32,25 @@ Generated stub adapter ``GeneratedStubGrpcInvoker`` adapts generated ``grpc/grpc`` stub clients using duck-typed call objects (``wait()``, ``responses()``/``read()``, ``write()``). +Stream call shape is resolved once from public method reflection when the +adapter is constructed. TalkingBytes never invokes a stream method merely to +probe its signature, and a ``TypeError`` raised inside a generated/user stub is +therefore never treated as a reason to invoke the method a second time. + +Explicit method maps are normalized and validated when the adapter is +constructed. Missing, non-public, or invalid mapped methods fail before the +first protocol call. + .. code-block:: php use Infocyph\TalkingBytes\Grpc\GrpcClient; - use Infocyph\TalkingBytes\Grpc\Native\GeneratedStubGrpcInvoker; $stub = new \Orders\OrderServiceClient('orders.internal:443', [ 'credentials' => \Grpc\ChannelCredentials::createSsl(), ]); - $adapter = new GeneratedStubGrpcInvoker($stub, [ + $client = GrpcClient::usingGeneratedStub($stub, [ '/orders.v1.OrderService/Create' => 'Create', ]); - $client = GrpcClient::usingNativeStreaming($adapter, $adapter); - Method map keys are gRPC method paths; values are PHP stub method names. diff --git a/docs/grpc/streaming.rst b/docs/grpc/streaming.rst index 8b29ae8..f5c13ae 100644 --- a/docs/grpc/streaming.rst +++ b/docs/grpc/streaming.rst @@ -67,3 +67,16 @@ Use explicit binary metadata APIs: - ``firstBinary('trace-bin')`` Non-binary metadata continues to use ``withValue()``, ``with()``, and ``values()``. + +Generated-stream cancellation +----------------------------- + +``GeneratedStubGrpcInvoker`` accepts an optional ``CancellationSignal``. +``GrpcClient::usingGeneratedStub()`` exposes the same optional cancellation +argument. + +Cancellation is checked between outbound writes and inbound response +deliveries. When cancellation or a consumer callback failure is observed, +TalkingBytes invokes the native call object's ``cancel()`` method when +available before propagating the failure. Streaming remains incremental; the +adapter does not buffer a complete stream in memory. diff --git a/docs/grpc/testing.rst b/docs/grpc/testing.rst index 87dd09c..8d8d441 100644 --- a/docs/grpc/testing.rst +++ b/docs/grpc/testing.rst @@ -6,6 +6,8 @@ Utilities - ``FakeGrpcCaller`` - ``AssertableGrpcCaller`` +- ``FakeGrpcInboundSource`` +- ``FakeGrpcInboundExchange`` Example ------- @@ -24,3 +26,12 @@ Example $fake->assert()->assertCallCount(1); Assertions cover method, payload, metadata, and call count behavior. + + +Inbound runtime testing +----------------------- + +Queue a normalized inbound request with ``FakeGrpcInboundSource::enqueue()``, +run one host cycle through ``GrpcInboundDispatcher::serveOne()``, then inspect +the returned fake exchange for completion and response status/message. This keeps +worker-loop tests deterministic without starting a socket server. diff --git a/docs/http/concurrency.rst b/docs/http/concurrency.rst index 563d4b8..9cdf4c5 100644 --- a/docs/http/concurrency.rst +++ b/docs/http/concurrency.rst @@ -32,7 +32,20 @@ Behavior -------- - user-defined keys are preserved -- max concurrency is enforced +- max concurrency is enforced with a rolling window rather than fixed chunks +- when one active handle completes, the next pending request is admitted immediately - per-request configuration is respected -- fail-fast mode is supported via request-pool options -- cleanup runs for active handles on early stop/error paths +- ``stopSchedulingOnFailure()`` stops only new admissions after a failure is observed; already-active requests are allowed to finish +- active requests are not described as fail-fast unless explicit cancellation is supplied +- cleanup runs for active handles on completion, cancellation, scheduler error, and early stop paths +- pool duration uses the monotonic clock + +Cancellation +------------ + +Pass a ``CancellationSignal`` to ``HttpClient::multi(..., cancellation: $signal)`` +or use ``RequestPool::withCancellation()``. + +When cancellation is observed, the pool stops admitting requests, removes active +cURL handles, aborts partial streamed-download temp files, and returns +deterministic cancelled results for active and not-yet-started requests. diff --git a/docs/index.rst b/docs/index.rst index e71dde2..894bc2a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,6 +33,7 @@ It provides: events middleware-and-resilience + resolved-composition testing security performance diff --git a/docs/performance.rst b/docs/performance.rst index c43459d..dea5d07 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -6,7 +6,8 @@ HTTP - use streaming download/upload for large payloads - set max body/download/upload limits -- use concurrent pool for high-latency fan-out +- use the rolling concurrent pool for high-latency fan-out +- scope mutable cookie/resilience collaborators deliberately in persistent workers Email ----- @@ -14,12 +15,30 @@ Email - use streaming message build/send paths for large attachments - use parser limits to bound inbound complexity - use lazy IMAP part fetch where attachment payload is deferred +- keep mailbox/socket connection state execution- or worker-owned gRPC ---- - set deadlines per call - enable retry only for idempotent/transient failures +- scope generated/native stubs and channels according to the host lifecycle + +Persistent runtimes +------------------- + +TalkingBytes does not use a process-global runtime registry for protocol state. +Long-lived hosts should reuse immutable graphs and explicitly scope mutable +collaborators such as cookie jars, resilience objects, mailbox sessions, native +gRPC clients, fakes, and spies. + +Use ``CancellationSignal`` to adapt host stop/heartbeat/release state into +retry, wait, stream, pool, mailbox, sendmail, and inbound gRPC boundaries. +Elapsed durations and internal deadlines use monotonic time; wall-clock time is +reserved for protocol semantics that require a real timestamp. + +Optional capabilities remain cold until selected. Do not preload native gRPC, +IMAP, POSIX, PCNTL, or Sodium merely because another protocol is active. General ------- @@ -27,15 +46,41 @@ General - keep retries bounded with backoff and jitter - emit events/metrics for timing and failure analysis - use fake transports for local and CI determinism +- keep CPU microbenchmarks separate from network, disk, and child-process I/O Benchmarks ---------- Run the repeatable component benchmark suite with ``composer ic:benchmark``. -Record the PHP version, extensions, OPcache state, operating system, and -hardware when comparing results. These microbenchmarks cover repeated HTTP -request preparation, email building/parsing, and webhook verification; they do -not establish production application RPM. Measure end-to-end sustained -successful RPM separately on the production-equivalent host application and -include concurrency, failures, timeouts, latency percentiles, and memory in the -result. +Record the PHP version, extensions, OPcache state, operating system, hardware, +and peak memory where meaningful before comparing runs. + +The native suite includes: + +- HTTP request/auth preparation, immutable and resolved factory construction, + fake/cookie-enabled sends, and middleware/resilience composition +- webhook signing, parsing, verification, replay claim, and duplicate rejection +- gRPC request construction, unary callback dispatch, and inbound dispatch +- email preparation, raw build/stream build, parser, null send, fake send, and + the existing 1/10/25 MB streaming payload cases +- resilience primitive overhead + +The rolling cURL-multi scheduler is also covered by deterministic local-server +tests that prove a freed concurrency slot is refilled before a slow peer +completes. Treat end-to-end network/process benchmarks separately from CPU +microbenchmarks. + +Soak evidence +------------- + +``tests/RuntimeSoakTest.php`` repeatedly creates and releases protocol graphs +and uses ``WeakReference`` plus garbage collection to detect accidental +process-global retention. It also verifies that new mutable fake/replay graphs +start with clean state. This is intentionally machine-independent; do not replace +it with fragile absolute memory or timing thresholds. + +These benchmarks and soak tests do not establish production application RPM. +Measure sustained successful RPM separately on the production-equivalent host +application and include concurrency, failures, timeouts, latency percentiles, +and memory in that result. Foundation owns direct-versus-TalkingBytes bridge +attribution. diff --git a/docs/release-checklist.rst b/docs/release-checklist.rst index cd577e9..50ad210 100644 --- a/docs/release-checklist.rst +++ b/docs/release-checklist.rst @@ -15,23 +15,43 @@ Pre-release gates - verify the supported PHP and dependency-version matrix is green in CI - verify the Mailpit integration job is green +- verify the optional-capability coldness job is green with unloadable gRPC, + IMAP, and POSIX extensions disabled +- verify PCNTL has no runtime references and Sodium remains confined to the + Ed25519 DKIM implementation +- verify no primary runtime path depends on ``CommunicationEventBus`` +- verify temporary process-global error handlers are restored and do not span + user callbacks, event dispatch, Fiber suspension, or long-lived waits +- verify elapsed/deadline paths use monotonic time where protocol wall time is + not required +- verify cancellation cleanup for retry, stream, pool, mailbox, and sendmail + paths - verify no sensitive values in emitted events/log metadata +- verify secret/PII sentinel tests remain green - verify fake transports and smoke tests stay green +- verify repeated-run soak checks show no unbounded handle/state growth Protocol readiness ------------------ -- HTTP: streaming, limits, security guards, pool behavior validated -- gRPC: retry and fake/native boundaries validated -- Webhook: sign/verify/replay/redaction behavior validated -- Email: SMTP/IMAP/POP3/parser/bounce/auth flows validated +- HTTP: streaming, limits, security guards, rolling pool scheduling, cancellation + cleanup, and result ordering validated +- gRPC: retry, generated/native adapter determinism, inbound accepted-exchange + boundary, streaming cleanup, and wire-error redaction validated +- Webhook: sign/verify/replay/redaction behavior validated; replay claims are + atomic and fail closed by contract +- Email: SMTP/IMAP/POP3/parser/bounce/auth flows validated; sendmail timeout and + portable/POSIX process cleanup validated +- mutable cookie, resilience, mailbox, native-client, fake, and spy lifetimes + remain explicit Documentation readiness ----------------------- - README examples are current -- docs/ pages reflect API and module boundaries +- docs/ pages reflect API, lifetime, cancellation, and ownership boundaries - extension requirements/suggestions are consistent with composer metadata +- optional capability behavior matches the minimal-extension CI gate - public documentation links resolve for the release version - the versioning and compatibility boundary remains accurate @@ -40,5 +60,9 @@ Versioning - update changelog or release notes - review the public API snapshot before accepting any breaking change -- compare component benchmarks with the accepted baseline -- tag only after CI, integration, release-guard, and documentation checks pass +- compare native component benchmarks with the accepted baseline +- record PHP version, extension set, OPcache state, operating system, hardware, + peak memory where meaningful, and benchmark class/methods used +- freeze the exact release head before the final supported matrix +- tag only the exact commit whose CI, integration, release-guard, benchmark, + soak, and documentation gates passed diff --git a/docs/resolved-composition.rst b/docs/resolved-composition.rst new file mode 100644 index 0000000..f89b05b --- /dev/null +++ b/docs/resolved-composition.rst @@ -0,0 +1,80 @@ +Resolved Protocol Composition +============================= + +Purpose +------- + +Hosts such as Foundation often own named profiles, application paths, secret +resolution, production policy, and DI lifetime. TalkingBytes should own the +protocol mechanics after those values are resolved. + +The resolved composition APIs keep that boundary explicit: + +- ``HttpClient::fromResolvedConfig()`` applies HTTP auth, cookies, retry, + rate limiting, circuit breaking, and idempotency. +- ``EmailSenderFactory::fromResolvedConfig()`` creates the selected transport + and composes fallbacks, retry, rate limiting, and DKIM. +- ``EmailLimits::fromArray()`` parses parser limits natively. +- ``GrpcClientFactory`` centralizes callable/native/generated client creation + and retry-profile application. +- ``GrpcClient::usingGeneratedStub()`` is the direct generated-stub convenience + path. +- ``Webhook::senderFromResolvedConfig()``, + ``Webhook::verifierFromResolvedConfig()`` and + ``Webhook::receiverFromResolvedConfig()`` apply resolved protocol policy. + +Host responsibilities +--------------------- + +The host should resolve these before calling TalkingBytes: + +- the selected profile name; +- relative application paths; +- secret source and production-secret policy; +- DI lifetime and sharing policy; +- application handler/service lookup; +- concrete replay-store implementation. + +TalkingBytes does not become a named-profile repository or service container. + +HTTP +---- + +.. code-block:: php + + $client = HttpClient::fromResolvedConfig([ + 'timeoutSeconds' => 10, + 'auth' => [ + 'driver' => 'bearer', + 'token' => $resolvedToken, + ], + 'retry' => [ + 'enabled' => true, + 'attempts' => 3, + 'base_delay_ms' => 250, + 'max_retry_after_seconds' => 30, + ], + ]); + +Email +----- + +``EmailSenderFactory::fromResolvedConfig()`` expects a resolved primary +``transport`` array, optional resolved ``fallbacks`` arrays, and optional +``retry``, ``rate_limit`` and ``dkim`` sections. File paths in log/spool/DKIM +configuration should already be absolute or otherwise resolved by the host. + +gRPC +---- + +``GrpcClientFactory`` accepts the resolved retry section while the host supplies +the callable/native/generated endpoint object. Generated stubs no longer need +host code to construct ``GeneratedStubGrpcInvoker`` for ordinary use. + +Webhook +------- + +Outbound resolved configuration can include ``signing_secret`` and ``retry``. +Inbound resolved configuration can include ``max_age_seconds``, +``max_payload_bytes`` and replay TTL/namespace. The replay-store object itself +remains host-provided. diff --git a/docs/security.rst b/docs/security.rst index 64dcc66..0089bcd 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -39,3 +39,27 @@ Operational guidance - Keep ``STARTTLS required`` for SMTP/IMAP in production. - Use explicit limits for large mailbox scans and inbound parser workloads. - Treat incoming authentication headers (SPF/DKIM/DMARC) as signal, not trust-on-first-use. + +Observability and data minimization +----------------------------------- + +Default protocol events are intentionally less detailed than caller-facing +``CommunicationResult`` diagnostics. + +- HTTP event headers redact authorization, proxy authorization, cookies, API + keys, auth tokens, and TalkingBytes webhook signatures. +- HTTP and webhook failure events emit stable failure categories instead of raw + transport error strings. +- Webhook events never include signing secrets, signature values, or payload bodies. +- gRPC events emit method/status/count information and may include a local + exception class, but not metadata values, request bodies, exception messages, + traces, or remote wire exception details. +- Email lifecycle/logging events use recipient counts and stable failure + categories; subjects, bodies, addresses, raw transport errors, and arbitrary + transport metadata are not copied into default observability. +- Spool receiver events do not expose absolute paths or parsed subjects. +- Mailbox authentication commands redact both usernames and credentials. + +Rich caller-facing errors remain available through returned result/response +objects where the protocol API documents them. Apply application logging policy +before recording those values. diff --git a/docs/webhook/end-to-end.rst b/docs/webhook/end-to-end.rst index 41dfe93..56a38aa 100644 --- a/docs/webhook/end-to-end.rst +++ b/docs/webhook/end-to-end.rst @@ -57,7 +57,7 @@ Replay protection Security behavior ----------------- -- signature format: ``t=,v1=`` +- signature format: ``t=,v2=`` (binds event and delivery ID) - timestamp window enforced (default 300s) - secret/signature/raw body are not emitted in event payloads - reserved headers are sender-controlled: diff --git a/docs/webhook/replay.rst b/docs/webhook/replay.rst index fa5654d..df521aa 100644 --- a/docs/webhook/replay.rst +++ b/docs/webhook/replay.rst @@ -4,7 +4,10 @@ Replay Protection Interface --------- -Use ``WebhookReplayStore`` to prevent duplicate processing. +Use ``WebhookReplayStore`` to prevent duplicate processing. The native receiver +requires a ``v2`` signature authenticating both event and delivery ID before +claiming replay state. Changing either header invalidates the signature. +Backend atomicity alone cannot secure an unsigned delivery ID. .. code-block:: php @@ -31,5 +34,14 @@ smaller bound for constrained processes. Production guidance ------------------- -Use a Redis/database-backed atomic insert-if-absent operation with TTL. A +Use a Redis/database-backed atomic insert-if-absent operation with TTL. The +atomic claim must provide one-winner semantics across competing processes; a separate check-then-write implementation is race-prone and is not sufficient. + +Replay backend errors are fail-closed. Implementations must throw when the +atomic claim cannot be completed instead of treating an unavailable backend as +an unused delivery ID. + +The built-in in-memory store is single-process only. It is appropriate for +tests and local development, not for multi-worker or multi-node replay +protection. diff --git a/docs/webhook/sender.rst b/docs/webhook/sender.rst index a6214a6..d477927 100644 --- a/docs/webhook/sender.rst +++ b/docs/webhook/sender.rst @@ -40,3 +40,32 @@ Reserved headers Reserved webhook headers are sender-controlled and protected from override so event/delivery/signature semantics stay authoritative. + +Signature interoperability +-------------------------- + +Signed native deliveries use ``t=,v2=``. For HMAC-SHA256, +the signed bytes are the decimal timestamp, a dot, and the following bytes: + +.. code-block:: text + + talkingbytes.webhook.v2eventdeliveryIdrawBody + +```` denotes one zero byte. Event and delivery ID cannot contain control +characters; the body is included exactly as transmitted, without JSON +re-encoding. Custom ``WebhookSigner`` implementations receive this bound payload +and the timestamp. The native receiver verifies HMAC-SHA256. + +``WebhookSignature::buildHeader($body, $timestamp, $event, $deliveryId)`` +produces the same native signature. Its two-argument form retains legacy +body/timestamp-only ``v1`` signing for direct integrations. + +Security upgrade from 2.0 +------------------------- + +Upgrade senders and receivers together. TalkingBytes 2.1 receivers reject +legacy ``v1`` deliveries, and 2.0 receivers do not understand ``v2``. This is an +intentional security compatibility correction: accepting unsigned delivery IDs +allows replay protection to be bypassed, and unsigned event names allow event +substitution. There is no automatic downgrade or insecure receiver opt-out. +External senders must implement the byte format above before switching traffic. diff --git a/docs/webhook/verifier-receiver.rst b/docs/webhook/verifier-receiver.rst index c49680e..2e36d01 100644 --- a/docs/webhook/verifier-receiver.rst +++ b/docs/webhook/verifier-receiver.rst @@ -10,7 +10,14 @@ Verifier - timestamp validity and tolerance window - HMAC value with constant-time ``hash_equals`` -Signature format is ``t=,v1=``. +Native delivery signature format is ``t=,v2=``. The signature +binds the timestamp, event, delivery ID, and exact raw body. Receivers require +``v2`` and never fall back to ``v1``. + +For direct verification, pass ``event`` and ``deliveryId`` to +``verifyResult()``. Without those arguments, the low-level verifier retains +legacy ``v1`` body/timestamp verification only; it does not authenticate routing +or replay identity. Verification result ------------------- @@ -27,9 +34,9 @@ Receiver ``WebhookReceiver`` performs: -1. verify signature -2. decode JSON payload -3. validate event + delivery id +1. validate event + delivery id +2. verify the signature including those fields +3. decode JSON payload 4. optional replay store check 5. return ``WebhookEvent`` diff --git a/plan.md b/plan.md deleted file mode 100644 index a29e595..0000000 --- a/plan.md +++ /dev/null @@ -1,3583 +0,0 @@ -# TalkingBytes — Final Next-Major Release Engineering Draft - -## Release Policy - -This is the final engineering specification for the next major release of `infocyph/talkingbytes`. - -### Breaking Changes - -Backward compatibility is **not required**. - -Prefer: - -```text -correctness -→ security -→ protocol compliance -→ performance -→ scalability -→ API clarity -→ compatibility -``` - -Existing APIs may therefore be redesigned where necessary. - -### Feature Preservation - -This release is **not intended to drop TalkingBytes capabilities**. - -Existing supported/intended capabilities should remain and be improved. - -If an existing implementation or API is: - -- insecure; -- misleading; -- incomplete; -- inefficient; -- architecturally incorrect; - -replace/redesign it rather than unnecessarily removing the underlying capability. - -### PHPForge - -Keep exactly: - -```json -"infocyph/phpforge": "dev-main@dev" -``` - -Do not replace it with a tagged PHPForge version. - -### Runtime Dependency Policy - -Keep TalkingBytes lightweight. - -Do not add ArrayKit, Intermix, CacheLayer, DBLayer, Omnibus, or another runtime dependency merely to replace small internal utilities. - -Add a runtime dependency only where it provides substantial functionality that TalkingBytes should not reasonably implement itself. - ---- - -# 1. Product Direction - -TalkingBytes should be a **protocol-aware PHP communication toolkit**. - -Its four primary communication domains are: - -```text -Email -HTTP -Webhook -gRPC -``` - -They should share only genuinely common infrastructure. - -Do not force every protocol through a universal communication abstraction. - ---- - -# 2. Protocol Responsibility - -## 2.1 Email - -Email is a complete **inbound + outbound mail communication system**. - -### Outbound - -```text -Application - ↓ -EmailMessage - ↓ -Prepared MIME representation - ↓ -DKIM/signing if configured - ↓ -Transport - ├── SMTP - ├── sendmail - ├── PHP mail() - ├── spool - ├── log - ├── null - └── fake -``` - -TalkingBytes should continue supporting: - -- From/To/Cc/Bcc; -- Reply-To/Sender/Return-Path; -- subject; -- text; -- HTML; -- MIME alternatives; -- attachments; -- inline attachments; -- templates; -- DSN; -- headers; -- SMTP; -- sendmail; -- PHP `mail()`; -- spool; -- logging; -- DKIM; -- retries; -- fallbacks; -- rate limits; -- testing/fakes. - -### Inbound - -```text -Mail source - ├── IMAP - ├── POP3 - └── spool - ↓ -Raw RFC822 message - ↓ -Parser - ├── headers - ├── MIME - ├── transfer decoding - ├── charset decoding - └── attachment extraction - ↓ -ParsedEmail - ├── body - ├── attachments - ├── DKIM - ├── Authentication-Results - ├── DSN - └── bounce classification -``` - -TalkingBytes should continue supporting the full inbound chain. - -Do not reduce Email to only an SMTP sender. - ---- - -# 3. HTTP Responsibility - -HTTP remains the first-class **outbound HTTP client**. - -Primary flow: - -```text -Application - ↓ -HttpClient - ↓ -HttpRequest - ↓ -Request preparation - ↓ -Security validation - ↓ -CurlTransport / CurlMultiTransport - ↓ -HTTP peer - ↓ -HttpResponse -``` - -Primary use cases: - -- REST APIs; -- external APIs; -- service integrations; -- authenticated APIs; -- uploads; -- downloads; -- multipart; -- concurrent requests; -- signed requests; -- API clients. - -TalkingBytes HTTP should **not become an HTTP web framework/server**. - -Do not add unrelated: - -- controllers; -- routing; -- web application middleware; -- HTTP server lifecycle; -- framework request handling. - ---- - -# 4. Webhook Responsibility - -Webhook remains specialized HTTP communication for: - -```text -asynchronous callbacks -+ -signed event delivery -+ -verified event reception -``` - -Outbound: - -```text -WebhookMessage -→ delivery identity -→ signature -→ HTTP request -→ retry -→ result -``` - -Inbound: - -```text -raw body + headers -→ signature validation -→ timestamp validation -→ replay claim -→ payload decoding -→ WebhookEvent -``` - -Webhook should reuse the HTTP transport where appropriate without duplicating the HTTP client architecture. - ---- - -# 5. gRPC Responsibility - -gRPC should be a first-class **bidirectional service-to-service communication module**. - -It is particularly suited to internal services and microservices controlled by the same architecture. - -TalkingBytes should support both sides. - -## Outbound - -```text -Service A - ↓ -GrpcClient - ↓ -GrpcRequest - ↓ -metadata/deadline/policy - ↓ -native/generated gRPC client - ↓ -Service B -``` - -## Inbound - -```text -native gRPC runtime - ↓ -TalkingBytes inbound adapter/dispatcher - ↓ -GrpcInboundRequest - ↓ -application handler - ↓ -GrpcInboundResponse - ↓ -native runtime -``` - -gRPC should therefore remain: - -```text -client -+ -inbound dispatcher/adapter -+ -request/response models -+ -streaming -+ -metadata -+ -deadlines -+ -retry -+ -testing -``` - -Do not reduce it to an outbound client. - ---- - -# 6. Preferred Communication Roles - -Recommended architectural usage: - -```text -Internal service RPC - → gRPC - -External/general APIs - → HTTP - -Asynchronous callback integrations - → Webhook - -Mail infrastructure / users - → Email -``` - -gRPC should generally be preferred for controlled internal microservices where: - -- contracts are shared; -- protobuf/generated classes are acceptable; -- low communication overhead matters; -- persistent HTTP/2 connections help; -- streaming is useful; -- typed status/deadline semantics are valuable. - -HTTP remains preferable for broad interoperability and third-party APIs. - -Do not try to make one replace the other universally. - ---- - -# 7. gRPC Capability Model - -Preserve/support all meaningful RPC modes. - -## Unary - -```text -request → response -``` - -## Server Streaming - -```text -one request -→ many responses -``` - -## Client Streaming - -```text -many requests -→ one response -``` - -## Bidirectional Streaming - -```text -many requests -↔ -many responses -``` - -Streaming must remain incremental and bounded. - -Do not convert large/infinite streams into arrays internally. - ---- - -# 8. gRPC Runtime Boundary - -TalkingBytes should **not implement the gRPC wire/network stack**. - -Leave: - -```text -HTTP/2 -protobuf framing -socket listening -native channel management -``` - -to: - -- `ext-grpc`; -- `grpc/grpc`; -- generated stubs; -- appropriate native/runtime integrations. - -TalkingBytes owns: - -- application-facing request/response objects; -- method dispatch; -- native adapters; -- metadata; -- deadlines; -- retry policy; -- error/status mapping; -- streaming abstraction; -- observability; -- testing. - -Generated `.proto` classes remain external: - -```text -.proto -→ protoc -→ generated PHP -→ TalkingBytes integration -``` - ---- - -# 9. Core Architecture Redesign - -## 9.1 Shrink `Core` - -Core should contain only genuinely shared infrastructure. - -Target conceptually: - -```text -Core/ -├── Event/ -├── Result/ -└── Support/ -``` - -Do not place HTTP- or gRPC-specific concepts in Core merely to appear transport-neutral. - ---- - -# 10. Remove `CommunicationRequest` - -Current protocol requests already exist: - -```text -HttpRequest -GrpcRequest -``` - -Wrapping them inside a generic: - -```php -CommunicationRequest -``` - -creates: - -- additional allocations; -- `mixed` payloads; -- duplicated options; -- duplicated headers; -- runtime `instanceof`; -- weaker static analysis. - -For the next major: - -```text -HttpRequest → HTTP pipeline -GrpcRequest → gRPC pipeline -``` - -directly. - -Keep `CommunicationResult` if its shared result convention remains valuable. - -Do not replace `CommunicationRequest` with another generic envelope under a new name. - ---- - -# 11. Protocol-Specific Middleware - -Move protocol-aware policies to protocol modules. - -For example: - -```text -Http/Middleware/ -Grpc/Middleware/ -``` - -HTTP-specific: - -- authentication; -- headers; -- HTTP retry; -- HTTP idempotency; -- HTTP signing. - -gRPC-specific: - -- metadata; -- RPC deadline; -- RPC retry; -- RPC idempotency. - -Only genuinely generic mechanics belong in Core. - ---- - -# 12. HTTP P0 — Secure Redirect Handling - -Do not rely on cURL automatic redirect following when destination security must be enforced. - -Current effective pattern can become: - -```text -validated public URL -→ redirect -→ private destination contacted by cURL -→ destination rejected afterward -``` - -That is too late. - -Implement TalkingBytes-owned redirect processing: - -```text -validate current destination -→ execute one request -→ receive redirect -→ resolve Location -→ validate next destination -→ execute next request -``` - -Validate **every redirect before connection**. - -Cover: - -- absolute redirects; -- relative redirects; -- scheme-relative redirects; -- loops; -- maximum redirects; -- changed host; -- changed port; -- HTTPS → HTTP; -- private destination; -- blocked host; -- allow-listed host. - ---- - -# 13. HTTP P0 — Remove Security-Bypassing cURL Options - -Do not allow unrestricted public: - -```php -HttpRequest::option(CURLOPT_..., ...) -``` - -to override TalkingBytes-owned security/transport behavior. - -Arbitrary overrides can affect: - -- URL; -- TLS; -- redirect handling; -- callbacks; -- response limits; -- upload handling; -- proxy handling. - -Replace arbitrary cURL configuration with typed supported APIs. - -Remove/rework: - -```text -CurlOptions::additional -``` - -Do not expose a generic unsafe escape hatch unless an unavoidable real-world requirement appears. - ---- - -# 14. HTTP P0 — DNS Rebinding / TOCTOU - -Avoid: - -```text -security DNS lookup -→ validation -→ cURL performs independent DNS lookup -``` - -Resolve, validate and pin the destination where strict network protection is enabled. - -The actual connection should use an address that TalkingBytes already validated. - -Run the process again for every redirect. - ---- - -# 15. HTTP Private-Network Policy - -`blockPrivateNetworks()` should protect against more than RFC1918. - -Reject relevant non-public/special-use ranges including: - -- loopback; -- private; -- link-local; -- shared address space; -- multicast; -- unspecified; -- reserved; -- documentation/special-use networks; -- IPv6 equivalents. - -Define the policy as effectively: - -```text -globally routable destinations only -``` - -when strict protection is enabled. - ---- - -# 16. HTTP Proxy Security - -Explicitly define the interaction between: - -```text -proxy configuration -+ -private-network blocking -``` - -A remote proxy may perform DNS resolution independently. - -Do not promise strict destination protection when TalkingBytes cannot control or verify proxy-side routing. - -Either: - -- support a proxy-aware enforcement design; -- reject incompatible combinations; -- or require explicit acknowledgement. - ---- - -# 17. HTTP Configuration Precedence - -Freeze this rule: - -```text -explicit HttpRequest value -> -HttpClient configured default -> -library default -``` - -Apply consistently to: - -- timeout; -- connect timeout; -- redirects; -- maximum redirects; -- TLS; -- CA; -- proxy; -- user-agent; -- response limits; -- upload/download limits. - -Do not detect "unset" by comparing values to library defaults. - -Represent explicit vs unset state correctly. - ---- - -# 18. HTTP Header Precedence - -Freeze: - -```text -explicit request header -> -client default header -``` - -Do not let `withDefaultHeaders()` overwrite request-specific headers. - -If a client-controlled mandatory header is needed, model it separately instead of calling it a default. - ---- - -# 19. Deterministic HTTP Request Preparation - -Request mutation must not depend on fluent configuration order. - -Required conceptual order: - -```text -1. resolve client defaults -2. finalize request query -3. finalize body -4. merge default/request headers -5. apply ordinary authentication -6. apply cookies -7. establish idempotency identity -8. finalize canonical URL/body -9. sign -10. transport -``` - -After signing, nothing that participates in the signature may mutate. - ---- - -# 20. HTTP Retry Safety - -Default automatic retry should be safe. - -Naturally retryable/idempotent methods may include: - -```text -GET -HEAD -OPTIONS -PUT -DELETE -``` - -For: - -```text -POST -PATCH -``` - -require either: - -- a stable idempotency key; -- explicit application opt-in. - -Do not merely document that retries should be used carefully. - -Enforce sensible behavior. - ---- - -# 21. Stable Idempotency - -A logical request must use exactly one idempotency key across all attempts. - -```text -attempt 1 -= -attempt 2 -= -attempt N -``` - -Generate it before entering the retry loop. - -It must not depend on middleware registration order. - ---- - -# 22. Retry Policy Redesign - -Prefer stateless retry decisions over policy objects carrying hidden per-attempt mutable state. - -Conceptually: - -```php -RetryDecision { - bool retry; - int delayMs; -} -``` - -Evaluate using a retry context. - -Policy evaluation should be deterministic. - ---- - -# 23. Retry Arithmetic Safety - -All exponential/backoff calculations must be bounded. - -Prevent: - -```text -INF -NAN -integer overflow -float overflow -negative delays -unbounded Retry-After -``` - -before calling `usleep()`. - -Set sensible caps for: - -- attempts; -- delay; -- Retry-After. - ---- - -# 24. HTTP Repeatable Upload Sources - -Retryable uploads need repeatable sources. - -Suitable sources: - -```text -file path -seekable stream -source factory -bounded internal spool -``` - -Do not automatically retry an already-consumed non-seekable stream. - -For non-seekable streams: - -- reject; -- or explicitly spool once within a configured limit. - ---- - -# 25. HTTP Multipart Ownership - -All internally generated temporary multipart files must have explicit ownership. - -Clean them in `finally`. - -Do not leave temporary files behind from: - -- `addData()`; -- `addStream()`; -- failed requests; -- exceptions; -- retries. - -Do not implement stream multipart as: - -```text -stream -→ complete PHP string -→ temp file -``` - -for large input. - -Use incremental copying. - ---- - -# 26. Avoid Duplicate HTTP Body Serialization - -Signing, validation and transport should operate on one resolved body representation. - -Avoid: - -```text -serialize for signature -→ serialize again for cURL -``` - -For JSON/form/raw payloads, prepare once. - -Define multipart signing semantics explicitly. - ---- - -# 27. Reject URL Userinfo - -Reject URLs such as: - -```text -https://user:password@example.com -``` - -Require dedicated authentication APIs. - -This avoids: - -- credential leakage; -- URL reconstruction ambiguity; -- log exposure. - ---- - -# 28. Preserve Query Semantics - -Do not round-trip arbitrary URLs through `parse_str()`. - -Preserve: - -```text -?a=1&a=2 -``` - -and ordered duplicate query entries. - -Introduce an ordered query representation where needed. - ---- - -# 29. HTTP Response Headers - -Treat HTTP header names case-insensitively. - -These must be semantically identical: - -```text -Set-Cookie -set-cookie -SET-COOKIE -``` - -Preserve duplicate header values individually. - -Especially preserve multiple `Set-Cookie` fields. - ---- - -# 30. CookieJar Hardening - -Fix: - -## `Max-Age` - -`Max-Age` must override `Expires` regardless of attribute order. - -## Domain - -Do not claim browser-equivalent domain-cookie safety without a public-suffix strategy. - -Choose a conservative policy. - -Possible direction: - -- host-only by default; -- explicit domain-cookie policy; -- optional PSL support if justified. - -Keep: - -- path-boundary validation; -- domain-origin validation; -- cookie capacity bounds. - ---- - -# 31. HTTP Config Parsing - -Avoid dangerous casts such as: - -```php -(bool) 'false' -``` - -Use strict configuration parsing. - -Invalid security-critical values should preferably throw instead of silently becoming defaults. - ---- - -# 32. CurlMulti Cleanup - -Every path must clean: - -- easy handles; -- multi handle; -- upload handles; -- download temp resources; -- multipart temp files. - -Use outer `finally`. - -Listener/observer exceptions must not leak resources. - ---- - -# 33. CurlMulti Status Handling - -Explicitly handle non-success: - -```php -CURLM_* -``` - -results. - -Do not leave active requests with ambiguous result states. - ---- - -# 34. CurlMulti Select Handling - -Handle: - -```php -curl_multi_select() === -1 -``` - -using a bounded small sleep/backoff. - -Avoid CPU spinning. - ---- - -# 35. Pool Fail-Fast Semantics - -If `failFast` does not actually cancel active requests, either: - -- implement real fail-fast cancellation; -- or rename/document it accurately. - -Do not promise stronger behavior than provided. - ---- - -# 36. HTTP Streaming Preserve - -Keep the strong streamed-download model: - -```text -temporary destination -→ bounded streaming -→ atomic finalize -``` - -Also define: - -- existing-target replacement; -- file permissions; -- flushing; -- cleanup on failure. - ---- - -# 37. Email P0 — Prepared Email Model - -This should be one of the largest architectural changes. - -Introduce an internal prepared/frozen email representation such as: - -```text -PreparedEmail -``` - -or: - -```text -MimePlan -``` - -Exact naming is implementation-defined. - -Preparation should freeze: - -- Date; -- Message-ID; -- MIME boundaries; -- content types; -- transfer encodings; -- headers; -- body sources; -- attachment sources; -- size information where available. - -Flow: - -```text -EmailMessage -→ prepare once -→ optional DKIM -→ transport exact prepared message -``` - ---- - -# 38. DKIM Must Sign Exact Wire Representation - -Do not: - -```text -build A -→ DKIM sign A -→ rebuild B -→ transmit B -``` - -because generated: - -- Date; -- Message-ID; -- MIME boundaries; - -may differ. - -Required invariant: - -```text -DKIM signed bytes -= -transmitted bytes -``` - -for the canonicalized fields/body. - ---- - -# 39. Stable Email Identity - -For one logical send: - -```text -wire Message-ID -= -reported Message-ID -= -DKIM Message-ID -``` - -Generated `Date` must likewise remain stable. - -Mime boundaries must not regenerate on retry/fallback. - ---- - -# 40. Remove Full Pre-Render SMTP Inspection - -Do not fully encode/render an email merely to discover: - -- size; -- SMTPUTF8 requirement; -- 8BITMIME requirement; -- content characteristics. - -Use the prepared plan. - -Base64 size can be calculated from source size mathematically where available. - ---- - -# 41. Repeatable Email Attachment Sources - -Every attachment source involved in: - -- DKIM; -- retry; -- fallback; -- inspection; -- send; - -must be repeatable. - -Suitable source concepts include: - -```text -data -file -seekable stream -stream factory -bounded spool -``` - -Keep the internal class count minimal. - ---- - -# 42. Non-Seekable Email Streams - -Do not silently treat arbitrary resources as retry-safe. - -For `attachStream()`: - -- require seekability; -- accept a source factory; -- or explicitly spool a non-seekable stream within limits. - -Do not let inspection exhaust the stream and then send an empty attachment. - ---- - -# 43. Stream Ownership - -Document and enforce: - -```text -who owns the resource? -who closes it? -where does reading begin? -can it be replayed? -``` - -TalkingBytes must not unexpectedly close caller-owned resources. - -Internally opened resources must always be closed. - ---- - -# 44. Stream Size - -Allow a known size to be supplied where appropriate. - -Use it for: - -- SMTP SIZE; -- planning; -- early rejection. - -Always validate actual bytes while reading. - ---- - -# 45. Email True Streaming - -Target: - -```text -prepared MIME plan -→ emit headers -→ emit body parts -→ encode attachments incrementally -→ transport writer -``` - -Avoid complete MIME body materialization into `php://temp` followed by another read. - ---- - -# 46. Line Ending Normalization - -Do not normalize CR/LF independently for arbitrary chunks. - -A `\r\n` sequence can cross chunk boundaries. - -Either: - -- produce canonical CRLF upstream; -- or use a stateful normalizer. - ---- - -# 47. `attachData()` Copies - -After correctness is established, optimize `attachData()` to avoid unnecessary complete temporary stream copies. - -Process large data strings incrementally where useful. - -Benchmark before micro-optimizing. - ---- - -# 48. Structural Email Headers - -Prevent public arbitrary custom headers from overriding protocol-owned structural headers such as: - -```text -From -To -Cc -Bcc -Date -Message-ID -MIME-Version -Content-Type -Content-Transfer-Encoding -DKIM-Signature -``` - -Dedicated APIs should own them. - -In particular, a custom `Bcc` header must never defeat envelope-only BCC behavior. - ---- - -# 49. Reply-To Behavior - -Do not implicitly create: - -```text -Reply-To = From -``` - -when setting From. - -Mail clients already fall back to From when Reply-To is absent. - -Use: - -```text -from() -``` - -only for From. - -Use: - -```text -replyTo() -``` - -only when explicitly requested. - ---- - -# 50. Outbound Header Bounds - -Add: - -```text -max total header bytes -max header fields -max header field bytes -max physical line bytes -``` - -Outbound folding must remain protocol-safe. - -Reject unreasonably long unbreakable tokens. - ---- - -# 51. SMTP Local Domain Validation - -`localDomain` enters EHLO/HELO commands. - -Reject: - -- CR; -- LF; -- NUL; -- control characters; -- invalid whitespace; -- invalid/oversized identity forms. - -Prevent SMTP command injection. - ---- - -# 52. SMTP Authentication Security - -Do not send credentials over plaintext accidentally. - -Reject: - -```text -SmtpSecurity::None + credentials -``` - -and: - -```text -StartTlsOptional -+ -STARTTLS unavailable -+ -credentials -``` - -Prefer no insecure-authentication override unless a genuine consumer requirement arises. - ---- - -# 53. SMTP AUTH Capability - -Do not automatically guess `AUTH LOGIN` when the server does not advertise a supported AUTH method. - -Fail explicitly. - ---- - -# 54. SMTP Response Bounds - -Bound: - -- line size; -- multiline count; -- total response bytes. - -Reject truncated/overlong lines. - ---- - -# 55. SMTP Deadlines - -Socket read timeout alone is insufficient. - -Implement an overall command/operation deadline. - -A slow-drip server must not keep an operation alive indefinitely. - ---- - -# 56. SMTP TLS - -Use explicit secure TLS stream settings: - -```text -verify_peer -verify_peer_name -peer_name -SNI -secure crypto policy -``` - -Expose CA/client certificate settings where useful. - -Do not add an easy generic insecure switch. - ---- - -# 57. SMTP Transcript Redaction - -Keep the existing principle: - -- auth secret omitted; -- DATA represented safely; -- no credential leakage. - -Future debug features must preserve this. - ---- - -# 58. IMAP/POP3 Credential Guards - -Reject protocol command injection through username/password. - -At minimum reject: - -```text -CR -LF -NUL -ASCII controls -``` - -before command construction. - ---- - -# 59. IMAP/POP3 Authentication Security - -Do not silently send credentials over plaintext. - -Apply equivalent secure-auth rules to mailbox protocols. - ---- - -# 60. IMAP Literal Bounds - -Validate advertised literal size **before allocation/read**. - -Do not read a giant server-declared literal and only later apply `EmailLimits`. - ---- - -# 61. IMAP Response Bounds - -Bound: - -- line bytes; -- number of response lines; -- total response bytes; -- literal bytes. - ---- - -# 62. POP3 Multiline Bounds - -Bound multiline responses while reading. - -Do not first accumulate an arbitrary message and only afterward give it to the parser. - ---- - -# 63. Protocol Line Truncation - -A bounded `fgets()` result that reached the line limit without termination should not be accepted as a complete valid protocol line. - -Reject explicitly. - ---- - -# 64. Mailbox Command Deadlines - -Apply overall deadlines to IMAP/POP commands. - -Account for IDLE/watch behavior separately. - ---- - -# 65. Mailbox Limits - -Propagate one coherent limit policy through: - -```text -transport -→ raw fetch -→ parsed fetch -→ attachments -→ MIME parser -``` - -Do not instantiate unrelated default `EmailLimits` deep inside operations. - ---- - -# 66. Mailbox TLS - -Configure certificate verification explicitly for IMAP/POP. - -Include: - -- hostname verification; -- SNI; -- CA configuration when necessary. - ---- - -# 67. MIME Limits During Parsing - -Current safety limits should reject complexity **while constructing** the MIME tree. - -Track: - -- current depth; -- total parts; -- decoded bytes; -- attachment bytes/count; - -during parsing. - -Do not fully construct pathological data before rejecting it. - ---- - -# 68. Incremental Decoding Limits - -Base64/quoted-printable/body decoding should enforce byte bounds incrementally. - -Do not decode a huge payload and reject afterward. - ---- - -# 69. Header Limit Semantics - -Differentiate: - -```text -maxHeaderBytes -maxHeaderFields -maxHeaderLineBytes -``` - -Folded continuation lines are not separate header fields. - ---- - -# 70. Exact Raw Email Preservation - -`ParsedEmail::raw` should represent exact received bytes. - -Do not normalize it before storage. - -Normalized forms may be produced internally or stored separately. - -Exact raw preservation is critical for authentication/forensics. - ---- - -# 71. DKIM Raw Header Model - -Preserve raw signed header fields in addition to parsed/unfolded values. - -Conceptually: - -```text -name -raw bytes -unfolded value -``` - -DKIM verification must use appropriate representation based on canonicalization. - ---- - -# 72. DKIM Canonicalization - -Correctly support/test: - -```text -simple/simple -simple/relaxed -relaxed/simple -relaxed/relaxed -``` - -Use independently generated fixtures. - -Do not validate only TalkingBytes-generated mail against TalkingBytes verification. - ---- - -# 73. DKIM Required Headers - -Ensure `From` participates in signed headers according to DKIM requirements. - -Reject invalid signing configuration early. - ---- - -# 74. DKIM Duplicate Header Semantics - -Repeated names in: - -```text -h= -``` - -must consume header occurrences from the bottom correctly. - -Do not select the same final header repeatedly. - -Signer and verifier must agree. - ---- - -# 75. DKIM Algorithms - -TalkingBytes currently exposes RSA-SHA256 and Ed25519-SHA256 concepts. - -Because this release is not intended to drop capabilities: - -- fully implement Ed25519-SHA256 signing/verification where the PHP/OpenSSL/runtime capability permits; -- detect unsupported runtime capability explicitly; -- do not claim successful Ed25519 support on environments unable to provide it. - -RSA-SHA256 remains fully supported. - -Do not leave an enum/API option that can only throw because implementation is missing. - ---- - -# 76. Multiple DKIM Signatures - -Support messages containing more than one: - -```text -DKIM-Signature -``` - -Provide per-signature results and convenient: - -```text -any valid -all results -``` - -semantics. - -This supports forwarding, transitions and key rotation. - ---- - -# 77. DKIM Resource Bounds - -Bound untrusted: - -- signature header bytes; -- tag count; -- `d=`; -- `s=`; -- `h=`; -- `b=`; -- `bh=`. - -Prevent excessive DNS/crypto/parser work. - ---- - -# 78. DKIM Key Records - -Handle important key-record semantics deliberately: - -- `v=`; -- `k=`; -- `p=`; -- revoked/empty `p=`; -- unsupported key types. - -Do not blindly wrap every `p=` value into a public key. - ---- - -# 79. DKIM Optional Tags - -Explicitly support or reject tags such as: - -```text -l= -x= -``` - -Do not silently appear RFC-complete while ignoring security-relevant semantics. - ---- - -# 80. DKIM Cache TTL - -`CachedDkimPublicKeyResolver` must not cache keys or missing records forever. - -Use bounded: - -- positive TTL; -- negative TTL; -- capacity. - -Do not add CacheLayer solely for this. - ---- - -# 81. DKIM Clock - -Use the internal Clock abstraction for signing timestamps. - -This improves deterministic tests. - ---- - -# 82. Spool Atomicity - -Keep: - -```text -temporary/incomplete file -→ complete write -→ atomic rename -``` - -as the spool publishing model. - ---- - -# 83. Spool Size Precheck - -Check filesystem size before loading an entire `.eml` with `file_get_contents()`. - -Then enforce bounded read and parser limits. - ---- - -# 84. Spool Directory Scanning - -Avoid: - -```text -glob entire directory -→ sort -→ choose one -``` - -for every message in `receiveMany()`. - -Use one bounded scan/batch operation where possible. - ---- - -# 85. Spool Producer Contract - -Document that producers must publish complete messages atomically. - -Receiver should consume only finalized extensions. - ---- - -# 86. Spool Permissions - -Use conservative filesystem permission behavior for: - -- mail contents; -- metadata; -- failed messages; -- logs. - -Respect secure process `umask`. - ---- - -# 87. Failure Sidecars - -Bound and sanitize failure metadata. - -Do not persist arbitrarily large exception messages or control characters. - -Avoid secrets. - ---- - -# 88. Spool Directory Validation - -Prevent pathological overlaps between: - -```text -source -processing -success -failure -``` - -directories. - ---- - -# 89. Log Transport - -Stream large raw messages to the log file instead of always creating a complete PHP string first. - ---- - -# 90. Sendmail Output Bounds - -Drain process stdout/stderr to prevent blocking, but retain only bounded diagnostic data. - ---- - -# 91. Sendmail Timeout - -After timeout: - -```text -terminate -→ grace -→ force termination if needed -→ close -``` - -Do not allow `proc_close()` to block indefinitely. - ---- - -# 92. Email Event Redaction - -Do not blindly publish complete transport metadata in events. - -Use a centralized safe metadata allow-list/redactor. - ---- - -# 93. Observability Must Not Break Delivery - -An event listener/logger exception must not turn successful: - -- SMTP; -- sendmail; -- spool; -- HTTP; -- webhook; -- gRPC; - -communication into failure by default. - ---- - -# 94. IMAP Parser Corpus - -Expand captured/pathological fixtures for: - -- quotes; -- escaped strings; -- NIL; -- nested ENVELOPE; -- address groups; -- literals; -- multiple FETCH values; -- `BODY[]`; -- `BODY.PEEK[]`; -- partial sections; -- nested BODYSTRUCTURE; -- UTF7-IMAP; -- delimiters; -- untagged responses; -- malformed literal declarations; -- IDLE. - -Keep the native implementation rather than replacing it with `ext-imap`. - ---- - -# 95. Webhook Atomic Replay - -Replace non-atomic: - -```text -seen() -remember() -``` - -with atomic claim semantics. - -Conceptually: - -```php -claim(namespace, deliveryId, ttl): bool -``` - -`true`: - -```text -first claimant -``` - -`false`: - -```text -already claimed -``` - -Support Redis/database implementations cleanly without forcing those dependencies into TalkingBytes. - ---- - -# 96. Replay Namespace - -Replay identity should include an endpoint/provider/application namespace. - -Do not globally identify deliveries solely by caller-supplied delivery ID. - ---- - -# 97. Replay Semantics Documentation - -Clearly state: - -```text -replay claim -≠ -exactly-once business processing -``` - -TalkingBytes prevents duplicate acceptance within the configured replay model. - -Application processing may still fail after acceptance. - ---- - -# 98. Multiple Webhook Signatures - -Support: - -```text -t=timestamp,v1=signature1,v1=signature2 -``` - -Verification succeeds when an allowed signature matches a configured secret. - ---- - -# 99. Webhook Secret Rotation - -Support bounded active secrets such as: - -```text -current -previous -``` - -without requiring consumers to build duplicate receiver pipelines. - ---- - -# 100. Webhook Signature Bounds - -Bound: - -- signature header bytes; -- tag segments; -- signature count; -- malformed/invalid hex input. - ---- - -# 101. Webhook Payload Bounds - -Add/configure a maximum body size. - -Reject overly large input before expensive: - -- signature work; -- JSON decoding; - -where possible. - ---- - -# 102. Webhook Identifier Guards - -Reject relevant ASCII control characters in event/delivery values. - -Keep sensible length bounds. - ---- - -# 103. One Webhook Retry Layer - -Do not allow: - -```text -WebhookSender retries -× -HttpClient retries -``` - -to multiply network attempts invisibly. - -Webhook delivery should have one authoritative retry engine. - ---- - -# 104. Webhook Per-Attempt Signature Time - -For every actual network attempt: - -```text -same delivery ID -+ -new timestamp -+ -new signature -+ -correct attempt number -``` - -Do not sign once and retry hours later with a stale timestamp. - ---- - -# 105. Stable Webhook Delivery Identity - -Keep one logical delivery ID across attempts. - -This existing behavior is correct. - ---- - -# 106. Webhook Fake Parity - -Fake sender must validate the same message readiness requirements as production sender. - -A fake should not report success for input the real transport would reject. - ---- - -# 107. Webhook User-Agent - -Remove hardcoded: - -```text -TalkingBytes/1.0 -``` - -Use a correct stable version strategy. - -Avoid manually duplicating package version across the codebase. - ---- - -# 108. Event Architecture - -Remove global static dispatcher state as the main mechanism. - -Clients/senders should receive an `EventDispatcher`. - -Default: - -```text -NullEventDispatcher -``` - -keeps disabled observability lightweight. - ---- - -# 109. Event Failure Policy - -Default dispatch should be best-effort. - -Observability is secondary to communication correctness. - -If strict/fail-fast event handling is ever needed, make it explicit. - ---- - -# 110. Resource Cleanup Before Event Failure - -All: - -- sockets; -- cURL handles; -- process resources; -- temporary files; -- streams; - -must be cleaned using `finally` independently of listener behavior. - ---- - -# 111. Remove Redundant Email Event Wrappers - -After the common event architecture is established, remove redundant implementation wrappers that provide no actual Email-specific behavior. - -This is an internal architecture cleanup, not a loss of email-event capability. - ---- - -# 112. Event Documentation - -Current docs referencing nonexistent/reset semantics must be aligned with the final actual API. - -Do not document methods that do not exist. - ---- - -# 113. Rate Limiter - -Replace O(n) timestamp-filtering hot paths with a bounded algorithm such as token bucket. - -Desired properties: - -```text -O(1)-style hot path -bounded memory -monotonic timing -deterministic tests -``` - -Keep this process-local. - -Do not force distributed persistence into TalkingBytes. - ---- - -# 114. Circuit Breaker - -Implement actual: - -```text -Closed -→ Open -→ HalfOpen -→ Closed/Open -``` - -semantics. - -After cooldown, permit only a limited probe—preferably one—to determine recovery. - -Prevent a thundering herd after cooldown. - -An enum is appropriate for circuit state. - ---- - -# 115. Internal Clock - -Introduce a small internal clock abstraction. - -Use where time controls behavior: - -- retry; -- webhook timestamps; -- replay; -- DKIM; -- rate limiter; -- circuit breaker; -- deadlines; -- command timeouts. - -Use monotonic time for elapsed durations where appropriate. - ---- - -# 116. Internal Sleeper - -Centralize blocking sleep/backoff behavior. - -Allows deterministic testing and consistent overflow-safe sleeps. - -Keep it internal unless consumers genuinely require injection. - ---- - -# 117. HTTP Signing Namespace - -HTTP request signing is HTTP-specific. - -Move generic-looking signing infrastructure under: - -```text -Http/Signing/ -``` - -or an equally clear HTTP namespace. - -Webhook signing remains in Webhook. - -Do not generalize unrelated protocols solely because both use HMAC. - ---- - -# 118. HMAC Key Validation - -Reject empty HMAC keys. - ---- - -# 119. Signed Request Nonce/Timestamp - -Custom nonce generators must produce: - -```text -non-empty -bounded -header-safe -``` - -values. - -Custom clocks must produce valid finite timestamps. - -Extension hooks must not bypass normal security guards. - ---- - -# 120. gRPC Internal Communication Role - -Treat gRPC as the main TalkingBytes option for efficient internal RPC where appropriate. - -Important benefits include: - -- generated typed contracts; -- compact protobuf payloads; -- HTTP/2 multiplexing; -- persistent channels; -- deadlines; -- metadata; -- canonical status codes; -- streaming. - -Do not market it as universally superior to HTTP. - -Use it where its RPC model fits. - ---- - -# 121. gRPC Inbound Capability - -Keep inbound request handling first-class. - -TalkingBytes should support: - -```text -native/runtime request -→ inbound adapter -→ method dispatch -→ GrpcInboundRequest -→ handler -→ GrpcInboundResponse -``` - -This is an intentional package capability. - ---- - -# 122. `GrpcServer` Naming - -The current object acts more like an inbound dispatcher than a full socket/network gRPC server. - -Because compatibility is not required, consider a clearer name such as: - -```text -GrpcInboundDispatcher -``` - -This is a naming/architecture correction only. - -**Do not remove inbound gRPC capability.** - ---- - -# 123. gRPC Retry Exceptions - -Do not automatically retry every thrown `Throwable`. - -Never retry programming/application problems by default, including: - -```text -TypeError -LogicException -invalid request -adapter contract errors -application handler errors -``` - -Only retry explicitly classified transient transport/runtime failures. - ---- - -# 124. gRPC Retry Statuses - -Use conservative retry defaults. - -Do not assume statuses such as: - -```text -Internal -Aborted -DeadlineExceeded -``` - -are universally retry-safe. - -Retry requires both: - -```text -transient condition -+ -retry-safe RPC semantics -``` - ---- - -# 125. gRPC RPC Idempotency - -gRPC method names do not automatically imply idempotency. - -Require explicit method/request retry safety for automatic retries. - -Do not accidentally retry: - -```text -Charge -Transfer -CreateOrder -``` - -merely because a transport failure occurred. - ---- - -# 126. Overall gRPC Deadline - -A logical RPC deadline covers the entire operation. - -Do not restart a full deadline on every retry. - -Required: - -```text -overall deadline -→ attempt -→ backoff consumes budget -→ remaining deadline -→ next attempt -``` - -Stop when the budget is exhausted. - ---- - -# 127. gRPC Deadline Validation - -Reject: - -```text -NAN -INF --INF -overflow -invalid negative values -``` - -before seconds/microseconds conversion. - -Apply to: - -- unary request; -- stream request; -- inbound request; -- deadline utilities. - ---- - -# 128. Deadline Propagation - -Support/enhance practical deadline propagation between internal services. - -Example: - -```text -Service A has 900 ms remaining -→ Service B receives remaining budget -→ Service B does not create a new independent 3-second budget -``` - -This is important for real microservice latency control. - ---- - -# 129. gRPC Metadata - -Keep first-class metadata support for: - -- auth; -- correlation IDs; -- tracing; -- tenant context; -- service metadata; -- binary values. - -Add bounds for: - -```text -key count -key length -value length -total metadata bytes -``` - ---- - -# 130. gRPC Status Semantics - -Preserve canonical gRPC status information. - -Do not reduce protocol result handling to only: - -```text -success / error string -``` - -`CommunicationResult` may wrap a protocol-specific `GrpcResponse`, but gRPC status remains authoritative. - ---- - -# 131. gRPC Streaming Policy - -Unary middleware and streaming middleware/policies are not automatically equivalent. - -Make streaming behavior explicit. - -Do not claim that a unary retry/timeout middleware applies to all streaming operations when it does not. - ---- - -# 132. Streaming Retry Safety - -A generator/client stream may be non-repeatable. - -Do not automatically retry consumed client/bidi streams. - -Retry requires an explicitly repeatable message source. - ---- - -# 133. Streaming Backpressure - -Do not buffer entire gRPC streams by default. - -Process messages incrementally. - -Allow consumer callbacks/iterators to control flow where supported by the native runtime. - ---- - -# 134. gRPC Cancellation - -Where supported by the native gRPC runtime, expose cancellation/abort semantics. - -A caller abandoning work should be able to communicate that state to the peer. - -Keep this within native adapter capabilities rather than implementing protocol internals. - ---- - -# 135. Generated gRPC Adapter Validation - -Current runtime duck-typing is flexible but failures should happen early. - -Validate expected native call capabilities immediately when opening a: - -- unary; -- server stream; -- client stream; -- bidi stream. - -Do not fail halfway through communication where avoidable. - ---- - -# 136. gRPC Method Identity - -Normalize method names internally to canonical: - -```text -/package.Service/Method -``` - -Avoid mismatches between method maps and requests. - -Convenience input may accept variants if normalization is unambiguous. - ---- - -# 137. gRPC Inbound Exception Privacy - -Do not expose raw application exception messages to remote gRPC callers. - -Map internal failures to: - -```text -INTERNAL -+ -safe external message -``` - -Send details only to local observability. - ---- - -# 138. Configuration Philosophy - -Prefer typed constructors as the main API. - -Keep `fromArray()` where useful. - -For array configuration: - -- parse booleans strictly; -- validate enums strictly; -- reject malformed security settings; -- avoid surprising fallback. - -Do not convert an explicit invalid configuration into another valid setting silently. - ---- - -# 139. Composer Runtime - -Keep runtime requirements lean. - -Current direction remains appropriate: - -```text -PHP 8.4+ -ext-curl -ext-fileinfo -ext-openssl -``` - -Consider: - -```json -"php": "^8.4" -``` - -if PHP 9 support is not explicitly tested. - -Otherwise retain `>=8.4` only with an intentional future-major CI policy. - ---- - -# 140. Optional Extensions - -Keep optional capabilities optional where appropriate: - -- `ext-grpc`; -- `grpc/grpc`; -- `ext-mbstring`; -- `ext-iconv`; -- `ext-imap`. - -TalkingBytes native mail parsing/mailbox behavior should not unnecessarily depend on `ext-imap`. - ---- - -# 141. PHPForge - -Final `composer.json` requirement: - -```json -"require-dev": { - "infocyph/phpforge": "dev-main@dev" -} -``` - -Ensure repository, release branch and reviewed snapshot agree before tagging. - ---- - -# 142. Do Not Add Unnecessary Infocyph Dependencies - -Specifically do not add: - -```text -ArrayKit -Intermix -CacheLayer -DBLayer -Omnibus -``` - -solely for: - -- configuration parsing; -- simple maps; -- tiny caches; -- basic retry logic; -- internal collections. - -Dependency-free core behavior remains a strength of TalkingBytes. - ---- - -# 143. Documentation — Architecture - -Update architecture docs to describe the actual final roles: - -```text -Email -→ inbound + outbound - -HTTP -→ outbound HTTP client - -Webhook -→ outbound + inbound signed callbacks - -gRPC -→ outbound + inbound service RPC -``` - -Do not call protocol-aware middleware universally transport-agnostic. - ---- - -# 144. Documentation — HTTP Security - -Document: - -- manual redirect validation; -- pre-connection destination checks; -- DNS rebinding protection; -- private/special-use policy; -- proxy implications; -- no arbitrary cURL bypass. - ---- - -# 145. Documentation — Retry - -Document separately: - -```text -HTTP retry -Webhook retry -gRPC retry -Email retry/fallback -``` - -Do not present all retry semantics as interchangeable. - -Document: - -- idempotency; -- repeatable streams; -- overall deadlines; -- maximum delays. - ---- - -# 146. Documentation — Email Streaming - -Only call a path streaming if large content is actually processed incrementally. - -Document source repeatability and ownership. - ---- - -# 147. Documentation — SMTP Authentication - -Align docs with the real no-auth model. - -If: - -```text -credentials === null -``` - -means no SMTP AUTH, document exactly that. - -Do not describe an enum value that does not exist. - ---- - -# 148. Documentation — DKIM - -Document: - -- supported algorithms; -- runtime capability requirements; -- canonicalizations; -- multiple signatures; -- DNS key caching; -- validation bounds; -- key rotation behavior. - ---- - -# 149. Documentation — Webhook Replay - -Clearly state replay protection guarantees and limitations. - ---- - -# 150. Documentation — gRPC - -Clearly state: - -- client capabilities; -- inbound dispatcher capabilities; -- streaming modes; -- deadlines; -- metadata; -- native runtime boundary. - -Do not imply TalkingBytes itself implements an HTTP/2 gRPC network server if it does not. - ---- - -# 151. Documentation — Events - -Remove stale examples such as nonexistent event-bus reset APIs unless the final API actually implements them. - ---- - -# 152. HTTP Test Matrix - -Require tests for: - -## Defaults - -- explicit timeout beats client default; -- redirects; -- TLS; -- proxy; -- headers; -- limits. - -## Preparation - -- auth then signing; -- query auth + signing; -- stable idempotency; -- fluent-order independence. - -## Query - -- duplicate keys; -- ordered duplicates; -- fragments; -- key edge cases; -- userinfo rejection. - -## SSRF - -- localhost; -- IPv4 private; -- IPv6 private; -- special-use; -- DNS rebinding; -- redirect to private; -- redirect chains; -- allow/block; -- proxy policy. - -## Retry - -- safe method; -- unsafe method; -- idempotency; -- transport classification; -- Retry-After; -- overflow. - -## Upload - -- file; -- seekable stream; -- non-seekable stream policy; -- retry; -- multipart cleanup. - -## Pool - -- multi errors; -- select `-1`; -- cleanup after exception; -- fail-fast semantics. - -## Headers/Cookies - -- mixed-case duplicates; -- Set-Cookie; -- Max-Age order; -- domain behavior; -- path boundary. - ---- - -# 153. Email Test Matrix - -Require: - -## Prepared Email - -- Date stable; -- Message-ID stable; -- boundary stable; -- reported/wire ID equal; -- retries preserve representation; -- fallback preserves representation. - -## Attachments - -- file; -- data; -- seekable stream; -- non-seekable; -- retry; -- fallback; -- DKIM; -- size exact/over-limit; -- inline; -- 1/10/25 MB. - -## DKIM - -External fixtures for: - -```text -simple/simple -simple/relaxed -relaxed/simple -relaxed/relaxed -``` - -plus: - -- folds; -- duplicates; -- multiple signatures; -- required From; -- RSA; -- Ed25519 where available; -- invalid/revoked keys; -- cache expiry; -- oversized tags. - -## MIME - -- deep trees; -- too many parts; -- too many attachments; -- oversized decoding; -- malformed boundaries; -- nested MIME; -- exact raw preservation. - -## SMTP - -- EHLO injection; -- plaintext auth rejection; -- STARTTLS downgrade; -- unsupported AUTH; -- response limits; -- total deadline; -- transcript secrets. - -## IMAP/POP - -- credential injection; -- insecure auth; -- literal limits; -- response limits; -- overlong lines; -- command deadlines; -- TLS. - -## Spool/Sendmail/Log - -- atomic publication; -- size precheck; -- large directory; -- failure handling; -- sidecar limits; -- output flooding; -- timeout; -- large streaming logs. - ---- - -# 154. Webhook Test Matrix - -Require: - -- valid HMAC; -- invalid HMAC; -- stale timestamp; -- future boundary; -- multiple `v1`; -- current secret; -- previous secret; -- invalid secret; -- oversized signature; -- oversized payload; -- malformed segments; -- identifier control characters; -- atomic concurrent replay claim; -- replay namespace; -- stable delivery ID; -- new timestamp per attempt; -- new signature per attempt; -- one retry layer; -- fake/real parity; -- redaction. - ---- - -# 155. gRPC Test Matrix - -Require: - -## Unary - -- success; -- status mapping; -- transient transport retry; -- programming error no retry; -- idempotent retry; -- unsafe retry rejected; -- total deadline. - -## Deadline - -- zero; -- negative; -- NaN; -- infinity; -- overflow; -- remaining deadline propagation. - -## Metadata - -- normal; -- duplicate; -- binary; -- invalid keys; -- control characters; -- count limits; -- value limits; -- total limits. - -## Native Adapter - -- missing method; -- invalid call shape; -- unary; -- server stream; -- client stream; -- bidi stream; -- trailers/status. - -## Streaming - -- server; -- client; -- bidi; -- non-repeatable stream retry rejection; -- incremental consumption; -- callback errors; -- cancellation where runtime supports it. - -## Inbound - -- known method; -- unknown method; -- handler success; -- handler exception; -- no raw exception leakage; -- event-listener behavior. - ---- - -# 156. Adversarial Test Corpus - -Maintain reusable corpora for: - -## HTTP - -- unusual IP forms; -- IPv6; -- redirects; -- query encodings; -- headers; -- cookies; -- hostile URLs. - -## Email - -- malformed MIME; -- deep MIME; -- huge boundaries; -- malformed encodings; -- duplicate headers; -- folded headers; -- charsets; -- DSNs; -- bounce messages; -- DKIM variations; -- IMAP transcripts; -- POP3 responses. - -## Webhook - -- malformed tags; -- duplicate tags; -- oversized tags; -- multiple secrets; -- invalid signatures. - ---- - -# 157. Benchmark Expansion - -Keep existing PHPBench coverage and add: - -## HTTP - -- request creation; -- HeaderBag; -- QueryParams; -- buildUrl; -- 0/1/5/10 middleware/policies; -- auth; -- signing; -- body serialization; -- response collection; -- multipart. - -## Email - -- plain build; -- HTML; -- multipart; -- parse; -- 1/10/25 MB attachment; -- prepared email; -- DKIM RSA; -- DKIM Ed25519 where available; -- cached/uncached verification. - -## Webhook - -- sign; -- parse; -- verify; -- multiple secrets; -- replay claim. - -## gRPC - -- request creation; -- metadata; -- unary adapter; -- policy overhead; -- deadline calculation. - -## Resilience - -- token bucket allow/reject; -- breaker closed/open/half-open. - ---- - -# 158. End-to-End Performance Measurements - -Especially benchmark actual large payload behavior. - -## SMTP - -Test: - -```text -1 MB -10 MB -25 MB -``` - -Measure: - -- wall time; -- peak PHP memory; -- bytes copied; -- disk spill. - -## HTTP - -Measure: - -- large upload; -- multipart; -- large streamed download; -- concurrent downloads. - -## Email Parser - -Measure realistic: - -- attachment-heavy mail; -- MIME nesting; -- malformed near-limit data. - ---- - -# 159. Optimization Priority - -Optimize in this order: - -```text -remove repeated full payload work -→ remove repeated serialization -→ remove full-memory copies -→ remove repeated scans -→ bound allocations -→ reduce unnecessary hot-path objects -→ micro-optimize expressions -``` - -Do not increase architectural complexity for tiny benchmark wins. - ---- - -# 160. Release Validation - -Before tagging: - -```text -composer validate --strict -PHPForge full CI -PHP lint -static analysis -style -unit tests -integration tests -security audit -benchmark comparison -documentation build -``` - -Also verify a production-style consumer install: - -```bash -composer install --no-dev --classmap-authoritative -``` - ---- - -# 161. Documentation Gate - -Run: - -```bash -sphinx-build -W --keep-going -b html docs build/docs -``` - -Warnings fail the release. - -Verify all documentation examples match the actual final API. - ---- - -# 162. Optional Extension CI - -Test meaningful combinations: - -```text -mbstring present/absent -imap present/absent -iconv where available -grpc integration available -``` - -Graceful fallback behavior should actually be tested. - ---- - -# 163. Implementation Order - -## Phase 1 — Architecture - -- [ ] Freeze four protocol roles. -- [ ] Preserve all existing intended capabilities. -- [ ] Remove `CommunicationRequest`. -- [ ] Introduce typed protocol pipelines. -- [ ] Move protocol-specific middleware. -- [ ] Redesign event injection. -- [ ] Add Clock/Sleeper. -- [ ] Clean signing namespace. - -## Phase 2 — HTTP Security - -- [ ] Remove unrestricted cURL options. -- [ ] Implement manual redirects. -- [ ] Resolve/validate/pin DNS. -- [ ] Strengthen private/special-use blocking. -- [ ] Define proxy policy. -- [ ] Reject URL userinfo. -- [ ] Add SSRF tests. - -## Phase 3 — HTTP Semantics - -- [ ] Fix client/request precedence. -- [ ] Fix default-header precedence. -- [ ] Replace lossy query parsing. -- [ ] Freeze request preparation order. -- [ ] Stable idempotency. -- [ ] Idempotency-aware retry. -- [ ] RetryDecision/stateless policy. -- [ ] Overflow-safe backoff. - -## Phase 4 — HTTP Resources - -- [ ] Repeatable upload source model. -- [ ] Multipart temp ownership. -- [ ] Incremental multipart spooling. -- [ ] Response header normalization. -- [ ] Cookie fixes. -- [ ] CurlMulti cleanup/status/select. -- [ ] Correct fail-fast semantics. - -## Phase 5 — Prepared Email - -- [ ] Introduce prepared representation. -- [ ] Freeze Date. -- [ ] Freeze Message-ID. -- [ ] Freeze MIME boundaries. -- [ ] Freeze encodings. -- [ ] Repeatable attachment sources. -- [ ] Remove pre-render inspection. -- [ ] Stream prepared content. - -## Phase 6 — DKIM - -- [ ] Exact-wire signing. -- [ ] Raw inbound header model. -- [ ] Correct canonicalizations. -- [ ] Required From. -- [ ] Duplicate header semantics. -- [ ] Complete RSA support. -- [ ] Complete Ed25519 support where runtime supports it. -- [ ] Multiple signatures. -- [ ] Key record validation. -- [ ] Resource limits. -- [ ] TTL cache. - -## Phase 7 — Email Protocol Security - -- [ ] SMTP EHLO guard. -- [ ] SMTP secure authentication. -- [ ] SMTP bounds/deadlines. -- [ ] SMTP TLS. -- [ ] IMAP credential guard. -- [ ] POP3 credential guard. -- [ ] secure mailbox authentication. -- [ ] IMAP literal bounds. -- [ ] POP3 response bounds. -- [ ] mailbox deadlines. -- [ ] mailbox TLS. - -## Phase 8 — Parser / Spool / Process - -- [ ] MIME limits during parse. -- [ ] incremental decoded limits. -- [ ] exact raw preservation. -- [ ] header limits. -- [ ] spool precheck. -- [ ] spool scan optimization. -- [ ] spool permissions. -- [ ] sidecar bounds. -- [ ] log streaming. -- [ ] sendmail output bound. -- [ ] sendmail timeout hardening. - -## Phase 9 — Webhook - -- [ ] Atomic replay claim. -- [ ] Replay namespace. -- [ ] Multiple signatures. -- [ ] Secret rotation. -- [ ] Signature limits. -- [ ] Payload limit. -- [ ] One retry layer. -- [ ] Per-attempt timestamp/signature. -- [ ] Fake parity. -- [ ] Version/User-Agent cleanup. - -## Phase 10 — gRPC - -- [ ] Keep outbound client. -- [ ] Keep/enhance inbound handling. -- [ ] Clarify/rename inbound dispatcher. -- [ ] Preserve unary. -- [ ] Preserve server streaming. -- [ ] Preserve client streaming. -- [ ] Preserve bidi streaming. -- [ ] Transient error classification. -- [ ] RPC idempotency. -- [ ] Overall deadline. -- [ ] Deadline propagation. -- [ ] Deadline finite checks. -- [ ] Metadata limits. -- [ ] Streaming policy behavior. -- [ ] Streaming backpressure. -- [ ] Cancellation integration. -- [ ] Native-call validation. -- [ ] Method normalization. -- [ ] Error privacy. - -## Phase 11 — Resilience - -- [ ] Token-bucket rate limiter. -- [ ] Closed/Open/HalfOpen breaker. -- [ ] Single probe recovery. -- [ ] Clock/Sleeper integration. - -## Phase 12 — Quality - -- [ ] Expand corpus tests. -- [ ] Expand benchmarks. -- [ ] Large-payload tests. -- [ ] Integration tests. -- [ ] Optional-extension matrix. - -## Phase 13 — Docs / Release - -- [ ] Rewrite architecture. -- [ ] Rewrite protocol roles. -- [ ] Rewrite security. -- [ ] Rewrite retry. -- [ ] Rewrite email streaming. -- [ ] Rewrite DKIM. -- [ ] Rewrite Webhook replay. -- [ ] Rewrite gRPC service communication. -- [ ] Fix stale APIs/examples. -- [ ] Sync Composer. -- [ ] Full PHPForge CI. -- [ ] Sphinx warnings-as-errors. -- [ ] Consumer-install test. -- [ ] Benchmark acceptance. -- [ ] Release. - ---- - -# 164. Final Architecture Acceptance - -The next major is architecturally complete only when: - -- [ ] Email remains full inbound + outbound. -- [ ] HTTP remains a powerful outbound HTTP client. -- [ ] Webhook remains inbound + outbound callback communication. -- [ ] gRPC remains outbound + inbound service communication. -- [ ] gRPC unary remains supported. -- [ ] gRPC server streaming remains supported. -- [ ] gRPC client streaming remains supported. -- [ ] gRPC bidirectional streaming remains supported. -- [ ] Current functionality is not removed merely to simplify architecture. -- [ ] Unsafe functionality is redesigned rather than retained incorrectly. -- [ ] No universal generic request object weakens protocol types. -- [ ] Core contains only truly shared concerns. -- [ ] gRPC network/wire runtime remains delegated to native gRPC tooling. -- [ ] Internal services can communicate directly through gRPC without HTTP abstraction. -- [ ] HTTP remains suitable for external/general integrations. -- [ ] Webhook remains layered sensibly over HTTP. -- [ ] Email inbound/outbound share appropriate infrastructure without collapsing into one incorrect model. - ---- - -# 165. Final Security Acceptance - -Do not release until: - -- [ ] Redirect destinations are validated before connection. -- [ ] DNS rebinding protection exists under strict SSRF policy. -- [ ] Arbitrary CURLOPT cannot bypass safety. -- [ ] Proxy/security behavior is defined. -- [ ] URL userinfo is rejected. -- [ ] TLS defaults are secure. -- [ ] SMTP authentication cannot silently downgrade. -- [ ] IMAP/POP authentication cannot silently downgrade. -- [ ] SMTP localDomain cannot inject commands. -- [ ] Mailbox credentials cannot inject commands. -- [ ] SMTP responses are bounded. -- [ ] IMAP literals/responses are bounded. -- [ ] POP3 responses are bounded. -- [ ] MIME parser work is bounded during parsing. -- [ ] Webhook replay is atomic. -- [ ] Webhook signatures support rotation safely. -- [ ] Inbound gRPC exceptions do not expose internal details. -- [ ] Events/logs redact secrets. -- [ ] Listener failures cannot bypass cleanup. - ---- - -# 166. Final Correctness Acceptance - -- [ ] Request overrides beat defaults. -- [ ] Explicit headers beat default headers. -- [ ] Signing always occurs after final request mutation. -- [ ] Stable idempotency survives retry. -- [ ] Unsafe requests do not retry accidentally. -- [ ] Upload sources are actually repeatable before retry. -- [ ] Multipart files cannot leak. -- [ ] Duplicate HTTP headers behave case-insensitively. -- [ ] Cookie precedence is correct. -- [ ] DKIM signs the actual wire email. -- [ ] Email Date is stable. -- [ ] Email Message-ID is stable. -- [ ] MIME boundaries are stable. -- [ ] Reported Message-ID equals wire ID. -- [ ] Non-seekable attachments cannot silently disappear. -- [ ] Email retry/fallback cannot consume exhausted sources. -- [ ] Raw inbound email is preserved exactly. -- [ ] External DKIM fixtures pass. -- [ ] RSA DKIM works. -- [ ] Ed25519 DKIM works when supported. -- [ ] Multiple DKIM signatures work. -- [ ] Webhook retry timestamps remain valid. -- [ ] Webhook retry count reflects real network attempts. -- [ ] gRPC retry requires transient failure and retry-safe operation. -- [ ] gRPC overall deadline does not reset per attempt. -- [ ] Streaming semantics are explicit and truthful. - ---- - -# 167. Final Performance Acceptance - -- [ ] No unnecessary complete MIME pre-render before SMTP send. -- [ ] Large email attachments stream incrementally. -- [ ] Large HTTP uploads stream incrementally. -- [ ] Multipart does not duplicate entire streams. -- [ ] Parser limits prevent excessive allocation early. -- [ ] Rate limiter hot path is bounded. -- [ ] Circuit breaker hot path remains cheap. -- [ ] Event-disabled paths remain cheap. -- [ ] HTTP request preparation avoids repeated serialization. -- [ ] Email preparation avoids repeated MIME generation. -- [ ] gRPC streaming does not accumulate unbounded arrays. -- [ ] 1/10/25 MB email benchmarks are recorded. -- [ ] HTTP large transfer benchmarks are recorded. -- [ ] Benchmark regressions are reviewed before release. - ---- - -# 168. Final Release Gate - -Before tagging: - -- [ ] `composer validate --strict`. -- [ ] `infocyph/phpforge` remains `dev-main@dev`. -- [ ] PHPForge full CI passes. -- [ ] PHP lint passes. -- [ ] Static analysis passes. -- [ ] Style checks pass. -- [ ] Unit tests pass. -- [ ] Integration tests pass. -- [ ] Security/adversarial tests pass. -- [ ] Optional-extension matrix passes. -- [ ] Documentation builds with warnings as errors. -- [ ] Documentation examples match real APIs. -- [ ] Benchmarks are recorded. -- [ ] Large-payload benchmarks are accepted. -- [ ] Clean production consumer installation passes. -- [ ] Composer package/archive contents are inspected. -- [ ] Public repository Composer metadata matches release source. -- [ ] Runtime dependencies remain intentionally minimal. - ---- - -# 169. Explicit Non-Goals for This Major - -Do not broaden scope unnecessarily into: - -- HTTP application framework; -- controllers/router; -- full SMTP server; -- custom gRPC wire implementation; -- custom protobuf compiler; -- Kafka; -- AMQP; -- generic queue system; -- SMS; -- push notifications; -- Laravel-specific package; -- Symfony-specific package; -- service container; -- automatic reflection/DI system; -- generic distributed cache; -- generic distributed circuit breaker; -- template framework. - -These may be separate future projects or integrations. - ---- - -# 170. Final Product Definition - -After this major, TalkingBytes should be accurately described as: - -> **A high-performance, protocol-aware PHP communication toolkit providing complete email communication, secure HTTP client communication, authenticated webhook delivery/reception, and efficient bidirectional gRPC service communication.** - -The intended architectural roles are: - -```text -Email -→ complete inbound + outbound mail lifecycle - -HTTP -→ secure, reliable external/general HTTP communication - -Webhook -→ signed asynchronous HTTP callbacks in both directions - -gRPC -→ typed internal service-to-service request/response and streaming -``` - -The guiding design rule is: - -> **Share infrastructure where semantics genuinely match; preserve protocol-specific models where they do not.** - -The guiding implementation rule is: - -> **Do not remove capability merely for architectural cleanliness. Redesign unsafe or inefficient implementations while retaining the useful capability.** - -The guiding performance rule is: - -> **Eliminate repeated work, copies, scans and unnecessary abstractions before pursuing micro-optimizations.** - -The guiding release rule is: - -> **A feature is not complete merely because its happy path works; its retry, malformed-input, large-payload, concurrency, resource-cleanup and security behavior must also be correct.** - -Once every applicable item in this specification is implemented, tested, benchmarked and documented, freeze the architecture and release the next major version. \ No newline at end of file diff --git a/src/Core/Event/BestEffortEventDispatcher.php b/src/Core/Event/BestEffortEventDispatcher.php index 1a4039d..5bc5f28 100644 --- a/src/Core/Event/BestEffortEventDispatcher.php +++ b/src/Core/Event/BestEffortEventDispatcher.php @@ -10,6 +10,13 @@ { public function __construct(private EventDispatcher $dispatcher) {} + public static function wrap(?EventDispatcher $dispatcher): self + { + return $dispatcher instanceof self + ? $dispatcher + : new self($dispatcher ?? new NullEventDispatcher()); + } + /** @param array $payload */ public function dispatch(string $event, array $payload = []): void { diff --git a/src/Core/Support/CancellationSignal.php b/src/Core/Support/CancellationSignal.php new file mode 100644 index 0000000..e184b18 --- /dev/null +++ b/src/Core/Support/CancellationSignal.php @@ -0,0 +1,35 @@ +requested = Closure::fromCallable($requested); + } + + public static function fromCallable(callable $requested): self + { + return new self($requested); + } + + public static function never(): self + { + return new self(static fn(): bool => false); + } + + /** @phpstan-impure */ + public function isRequested(): bool + { + return (bool) ($this->requested)(); + } +} diff --git a/src/Core/Support/ObservabilitySanitizer.php b/src/Core/Support/ObservabilitySanitizer.php new file mode 100644 index 0000000..bd49ca5 --- /dev/null +++ b/src/Core/Support/ObservabilitySanitizer.php @@ -0,0 +1,76 @@ + */ + private const array SAFE_METADATA_KEYS = [ + 'accepted_count', + 'attempts', + 'cancelled', + 'duration_ms', + 'partial_success', + 'rejected_count', + 'started', + 'transport', + ]; + + /** @return array */ + public static function resultContext(CommunicationResult $result): array + { + $context = [ + 'successful' => $result->successful, + 'status_code' => $result->statusCode, + ]; + + if (!$result->successful) { + $context['failure_category'] = self::failureCategory($result); + } + + foreach (self::SAFE_METADATA_KEYS as $key) { + if (!array_key_exists($key, $result->metadata)) { + continue; + } + + $value = $result->metadata[$key]; + if (is_scalar($value) || $value === null) { + $context[$key] = $value; + } + } + + return $context; + } + + /** @return array{failure_category:string,exception_class:class-string} */ + public static function throwableContext(Throwable $throwable): array + { + return [ + 'failure_category' => 'exception', + 'exception_class' => $throwable::class, + ]; + } + + private static function failureCategory(CommunicationResult $result): string + { + if (($result->metadata['cancelled'] ?? false) === true) { + return 'cancelled'; + } + if (($result->metadata['scheduler_error'] ?? false) === true) { + return 'scheduler_error'; + } + if ($result->statusCode !== null) { + return 'protocol_status'; + } + if (is_string($result->metadata['exception'] ?? null)) { + return 'exception'; + } + + return 'transport_error'; + } +} diff --git a/src/Core/Support/RetryExecutor.php b/src/Core/Support/RetryExecutor.php index ebf5312..a2d94a7 100644 --- a/src/Core/Support/RetryExecutor.php +++ b/src/Core/Support/RetryExecutor.php @@ -18,11 +18,16 @@ public static function run( RetryPolicy $policy, callable $attempt, ?Sleeper $sleeper = null, + ?CancellationSignal $cancellation = null, ): CommunicationResult { $sleeper ??= Sleeper::system(); $count = 1; while (true) { + if ($cancellation?->isRequested() === true) { + return self::cancelled($count - 1); + } + try { $result = $attempt(); } catch (Throwable $throwable) { @@ -31,7 +36,9 @@ public static function run( throw $throwable; } - $sleeper->milliseconds($decision->delayMs); + if (!self::wait($sleeper, $decision->delayMs, $cancellation)) { + return self::cancelled($count); + } $count++; continue; @@ -42,8 +49,32 @@ public static function run( return $result; } - $sleeper->milliseconds($decision->delayMs); + if (!self::wait($sleeper, $decision->delayMs, $cancellation)) { + return self::cancelled($count); + } $count++; } } + + private static function cancelled(int $attempts): CommunicationResult + { + return CommunicationResult::failure( + 'Operation cancelled.', + metadata: ['cancelled' => true, 'attempts' => max(0, $attempts)], + ); + } + + private static function wait( + Sleeper $sleeper, + int $delayMs, + ?CancellationSignal $cancellation, + ): bool { + if ($cancellation === null) { + $sleeper->milliseconds($delayMs); + + return true; + } + + return $sleeper->millisecondsInterruptibly($delayMs, $cancellation); + } } diff --git a/src/Core/Support/Sleeper.php b/src/Core/Support/Sleeper.php index e804326..7032bf0 100644 --- a/src/Core/Support/Sleeper.php +++ b/src/Core/Support/Sleeper.php @@ -27,12 +27,7 @@ public static function system(): self public function milliseconds(int $delayMs): void { - if ($delayMs < 0 || $delayMs > self::MAX_DELAY_MS) { - throw new InvalidArgumentException(sprintf( - 'Sleep delay must be between 0 and %d milliseconds.', - self::MAX_DELAY_MS, - )); - } + $this->assertDelay($delayMs); if ($delayMs === 0) { return; @@ -40,4 +35,41 @@ public function milliseconds(int $delayMs): void ($this->sleep)($delayMs * 1000); } + + public function millisecondsInterruptibly( + int $delayMs, + CancellationSignal $cancellation, + int $sliceMs = 50, + ): bool { + $this->assertDelay($delayMs); + if ($sliceMs < 1 || $sliceMs > 1000) { + throw new InvalidArgumentException('Interruptible sleep slice must be between 1 and 1000 milliseconds.'); + } + if ($cancellation->isRequested()) { + return false; + } + + $remaining = $delayMs; + while ($remaining > 0) { + $current = min($remaining, $sliceMs); + ($this->sleep)($current * 1000); + $remaining -= $current; + + if ($cancellation->isRequested()) { + return false; + } + } + + return true; + } + + private function assertDelay(int $delayMs): void + { + if ($delayMs < 0 || $delayMs > self::MAX_DELAY_MS) { + throw new InvalidArgumentException(sprintf( + 'Sleep delay must be between 0 and %d milliseconds.', + self::MAX_DELAY_MS, + )); + } + } } diff --git a/src/Email/Config/DkimConfig.php b/src/Email/Config/DkimConfig.php index 3868b24..406101b 100644 --- a/src/Email/Config/DkimConfig.php +++ b/src/Email/Config/DkimConfig.php @@ -53,6 +53,58 @@ public function __construct( $this->assertPrivateKeyIsReadable($this->privateKey); } + /** + * @param array $config + */ + public static function fromArray(array $config): self + { + $domain = trim(ConfigValue::string($config, 'domain', '')); + $selector = trim(ConfigValue::string($config, 'selector', '')); + $algorithmName = ConfigValue::string($config, 'algorithm', DkimAlgorithm::RsaSha256->value); + $algorithm = DkimAlgorithm::tryFrom($algorithmName) + ?? throw new InvalidArgumentException('Unsupported DKIM algorithm.'); + + $headers = array_key_exists('headersToSign', $config) + ? ConfigValue::stringList($config, 'headersToSign', []) + : ConfigValue::stringList( + $config, + 'headers', + ['from', 'to', 'subject', 'date', 'message-id', 'mime-version', 'content-type'], + ); + + $privateKey = self::resolvedString($config, ['privateKey', 'private_key']); + $privateKeyPath = self::resolvedString($config, ['privateKeyPath', 'private_key_path']); + if ($privateKey !== null && $privateKeyPath !== null) { + throw new InvalidArgumentException('Configure either a DKIM private key or private key path, not both.'); + } + + if ($privateKeyPath !== null) { + if (!is_file($privateKeyPath) || !is_readable($privateKeyPath)) { + throw new InvalidArgumentException(sprintf('DKIM private key path is not readable: %s', $privateKeyPath)); + } + + $loaded = file_get_contents($privateKeyPath); + if (!is_string($loaded) || trim($loaded) === '') { + throw new InvalidArgumentException(sprintf('DKIM private key file is empty: %s', $privateKeyPath)); + } + $privateKey = $loaded; + } + + if ($privateKey === null) { + throw new InvalidArgumentException('DKIM signing requires a private key or private key path.'); + } + + return new self( + domain: $domain, + selector: $selector, + privateKey: $privateKey, + headersToSign: $headers, + algorithm: $algorithm, + headerCanonicalization: ConfigValue::string($config, 'headerCanonicalization', 'relaxed'), + bodyCanonicalization: ConfigValue::string($config, 'bodyCanonicalization', 'relaxed'), + ); + } + /** * @param list $headersToSign */ @@ -102,6 +154,27 @@ private static function assertDnsIdentifier(string $value, int $maximumBytes, st } } + /** + * @param array $config + * @param list $keys + */ + private static function resolvedString(array $config, array $keys): ?string + { + foreach ($keys as $key) { + $value = $config[$key] ?? null; + if (!is_string($value)) { + continue; + } + + $value = trim($value); + if ($value !== '') { + return $value; + } + } + + return null; + } + private function assertPrivateKeyIsReadable(string $privateKey): void { if ($this->algorithm === DkimAlgorithm::Ed25519Sha256) { diff --git a/src/Email/Config/EmailLimits.php b/src/Email/Config/EmailLimits.php index c7663a7..e7ce701 100644 --- a/src/Email/Config/EmailLimits.php +++ b/src/Email/Config/EmailLimits.php @@ -30,6 +30,24 @@ public function __construct( $this->assertPositive('maxHeaderLineBytes', $this->maxHeaderLineBytes); } + /** + * @param array $config + */ + public static function fromArray(array $config): self + { + return new self( + maxMessageBytes: ConfigValue::int($config, 'maxMessageBytes', 10_485_760), + maxAttachmentBytes: ConfigValue::int($config, 'maxAttachmentBytes', 26_214_400), + maxAttachmentCount: ConfigValue::int($config, 'maxAttachmentCount', 500), + maxDecodedBodyBytes: ConfigValue::int($config, 'maxDecodedBodyBytes', 10_485_760), + maxMimeDepth: ConfigValue::int($config, 'maxMimeDepth', 20), + maxMimeParts: ConfigValue::int($config, 'maxMimeParts', 500), + maxHeaderBytes: ConfigValue::int($config, 'maxHeaderBytes', 131_072), + maxHeaderCount: ConfigValue::int($config, 'maxHeaderCount', 2_000), + maxHeaderLineBytes: ConfigValue::int($config, 'maxHeaderLineBytes', 998), + ); + } + private function assertPositive(string $name, int $value): void { if ($value > 0) { diff --git a/src/Email/Email.php b/src/Email/Email.php index e8a2720..73f5f70 100644 --- a/src/Email/Email.php +++ b/src/Email/Email.php @@ -5,6 +5,9 @@ namespace Infocyph\TalkingBytes\Email; use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\Sleeper; final readonly class Email { @@ -16,18 +19,26 @@ public static function events(?callable $listener): void CommunicationEventBus::listen($listener); } - public static function mailbox(): EmailMailboxFactory - { - return new EmailMailboxFactory(); + public static function mailbox( + ?EventDispatcher $events = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ): EmailMailboxFactory { + return new EmailMailboxFactory($events, $clock, $sleeper); } - public static function receiver(): EmailReceiverFactory - { - return new EmailReceiverFactory(); + public static function receiver( + ?EventDispatcher $events = null, + ?Clock $clock = null, + ): EmailReceiverFactory { + return new EmailReceiverFactory($events, $clock); } - public static function sender(): EmailSenderFactory - { - return new EmailSenderFactory(); + public static function sender( + ?EventDispatcher $events = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ): EmailSenderFactory { + return new EmailSenderFactory($events, $clock, $sleeper); } } diff --git a/src/Email/EmailMailboxFactory.php b/src/Email/EmailMailboxFactory.php index 4085cd1..9bab677 100644 --- a/src/Email/EmailMailboxFactory.php +++ b/src/Email/EmailMailboxFactory.php @@ -4,6 +4,11 @@ namespace Infocyph\TalkingBytes\Email; +use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Email\Config\ImapConfig; use Infocyph\TalkingBytes\Email\Config\Pop3Config; use Infocyph\TalkingBytes\Email\Mailbox\Mailbox; @@ -11,13 +16,29 @@ final readonly class EmailMailboxFactory { + private Clock $clock; + + private EventDispatcher $events; + + private Sleeper $sleeper; + + public function __construct( + ?EventDispatcher $events = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ) { + $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); + $this->sleeper = $sleeper ?? Sleeper::system(); + } + public function usingImap(ImapConfig $config): Mailbox { - return Mailbox::usingImap($config); + return Mailbox::usingImap($config, $this->events, $this->clock, $this->sleeper); } public function usingPop3(Pop3Config $config): Pop3Mailbox { - return Pop3Mailbox::usingConfig($config); + return Pop3Mailbox::usingConfig($config, $this->events, $this->clock, $this->sleeper); } } diff --git a/src/Email/EmailReceiverFactory.php b/src/Email/EmailReceiverFactory.php index f5db998..ee0470e 100644 --- a/src/Email/EmailReceiverFactory.php +++ b/src/Email/EmailReceiverFactory.php @@ -4,6 +4,10 @@ namespace Infocyph\TalkingBytes\Email; +use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; +use Infocyph\TalkingBytes\Core\Support\Clock; use Infocyph\TalkingBytes\Email\Config\SpoolConfig; use Infocyph\TalkingBytes\Email\Parser\EmailParser; use Infocyph\TalkingBytes\Email\Parser\RawEmailParser; @@ -11,6 +15,16 @@ final readonly class EmailReceiverFactory { + private Clock $clock; + + private EventDispatcher $events; + + public function __construct(?EventDispatcher $events = null, ?Clock $clock = null) + { + $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); + } + public function usingSpool( SpoolConfig $config, ?EmailParser $parser = null, @@ -24,6 +38,8 @@ public function usingSpool( $deleteAfterRead, $moveAfterRead, $failedDirectory, + $this->events, + $this->clock, ); } } diff --git a/src/Email/EmailSenderFactory.php b/src/Email/EmailSenderFactory.php index 2356847..73eee5b 100644 --- a/src/Email/EmailSenderFactory.php +++ b/src/Email/EmailSenderFactory.php @@ -4,45 +4,217 @@ namespace Infocyph\TalkingBytes\Email; +use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\Sleeper; +use Infocyph\TalkingBytes\Email\Config\ConfigValue; +use Infocyph\TalkingBytes\Email\Config\DkimConfig; use Infocyph\TalkingBytes\Email\Config\LogEmailConfig; use Infocyph\TalkingBytes\Email\Config\SendmailConfig; use Infocyph\TalkingBytes\Email\Config\SmtpConfig; use Infocyph\TalkingBytes\Email\Config\SpoolConfig; +use Infocyph\TalkingBytes\Resilience\RateLimiter; +use Infocyph\TalkingBytes\Retry\ExponentialBackoffRetryPolicy; +use Infocyph\TalkingBytes\Retry\FixedDelayRetryPolicy; +use InvalidArgumentException; final readonly class EmailSenderFactory { + private Clock $clock; + + private EventDispatcher $events; + + private Sleeper $sleeper; + + public function __construct( + ?EventDispatcher $events = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ) { + $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); + $this->sleeper = $sleeper ?? Sleeper::system(); + } + public function fake(): Emailer { - return Emailer::fake(); + return Emailer::fake($this->events, $this->clock); + } + + /** + * Build an email sender from already-resolved protocol configuration. + * + * Expected top-level sections are transport, fallbacks, retry, rate_limit and dkim. + * Paths and secrets must already reflect host policy before reaching this boundary. + * + * @param array $config + */ + public function fromResolvedConfig( + array $config, + ?CancellationSignal $cancellation = null, + ): Emailer { + $transport = self::section($config, 'transport', required: true); + $emailer = $this->usingResolvedTransport($transport, $cancellation); + + $fallbackTransports = []; + foreach (self::sections($config, 'fallbacks') as $fallback) { + $fallbackTransports[] = $this->usingResolvedTransport($fallback, $cancellation)->transport(); + } + if ($fallbackTransports !== []) { + $emailer = $emailer->withFallback($fallbackTransports); + } + + $retry = self::section($config, 'retry'); + if (ConfigValue::bool($retry, 'enabled', false)) { + $attempts = ConfigValue::int($retry, 'max_attempts', 3); + $delayMs = ConfigValue::int($retry, 'delay_ms', 250); + $policy = match (ConfigValue::string($retry, 'policy', 'fixed')) { + 'backoff', 'exponential' => new ExponentialBackoffRetryPolicy($attempts, $delayMs), + 'fixed' => new FixedDelayRetryPolicy($attempts, $delayMs), + default => throw new InvalidArgumentException('Unsupported email retry policy.'), + }; + $emailer = $emailer->withRetry($policy, $cancellation); + } + + $rateLimit = self::section($config, 'rate_limit'); + if (ConfigValue::bool($rateLimit, 'enabled', false)) { + $emailer = $emailer->withRateLimit(new RateLimiter( + ConfigValue::int($rateLimit, 'max_requests', 60), + ConfigValue::int($rateLimit, 'per_seconds', 60), + )); + } + + $dkim = self::section($config, 'dkim'); + if (ConfigValue::bool($dkim, 'enabled', false)) { + $emailer = $emailer->withDkim(DkimConfig::fromArray($dkim)); + } + + return $emailer; } public function usingLog(LogEmailConfig $config): Emailer { - return Emailer::usingLog($config); + return Emailer::usingLog($config, $this->events, $this->clock); } public function usingMailFunction(): Emailer { - return Emailer::usingMailFunction(); + return Emailer::usingMailFunction($this->events, $this->clock); } public function usingNull(): Emailer { - return Emailer::usingNull(); + return Emailer::usingNull($this->events, $this->clock); } - public function usingSendmail(SendmailConfig $config = new SendmailConfig()): Emailer - { - return Emailer::usingSendmail($config); + public function usingSendmail( + SendmailConfig $config = new SendmailConfig(), + ?CancellationSignal $cancellation = null, + ): Emailer { + return Emailer::usingSendmail( + $config, + $this->events, + $this->clock, + $cancellation, + $this->sleeper, + ); } public function usingSmtp(SmtpConfig $config): Emailer { - return Emailer::usingSmtp($config); + return Emailer::usingSmtp($config, $this->events, $this->clock); } public function usingSpool(SpoolConfig $config): Emailer { - return Emailer::usingSpool($config); + return Emailer::usingSpool($config, $this->events, $this->clock); + } + + /** + * @param array $config + * @return array + */ + private static function section(array $config, string $key, bool $required = false): array + { + $value = $config[$key] ?? null; + if ($value === null && !$required) { + return []; + } + + if (!is_array($value)) { + throw new InvalidArgumentException(sprintf('Email resolved configuration section "%s" must be an array.', $key)); + } + + $section = []; + foreach ($value as $name => $item) { + if (is_string($name)) { + $section[$name] = $item; + } + } + + if ($required && $section === []) { + throw new InvalidArgumentException(sprintf('Email resolved configuration section "%s" must not be empty.', $key)); + } + + return $section; + } + + /** + * @param array $config + * @return list> + */ + private static function sections(array $config, string $key): array + { + $value = $config[$key] ?? []; + if (!is_array($value)) { + throw new InvalidArgumentException(sprintf('Email resolved configuration section "%s" must be a list.', $key)); + } + + $sections = []; + foreach ($value as $item) { + if (!is_array($item)) { + throw new InvalidArgumentException(sprintf('Email resolved configuration section "%s" must contain arrays.', $key)); + } + + $section = []; + foreach ($item as $name => $entry) { + if (is_string($name)) { + $section[$name] = $entry; + } + } + $sections[] = $section; + } + + return $sections; + } + + /** + * @param array $config + */ + private function usingResolvedTransport( + array $config, + ?CancellationSignal $cancellation, + ): Emailer { + $driver = trim(ConfigValue::string($config, 'driver', '')); + if ($driver === '') { + throw new InvalidArgumentException('Resolved email transport driver must be non-empty.'); + } + + return match ($driver) { + 'fake' => $this->fake(), + 'log' => $this->usingLog(LogEmailConfig::fromArray($config)), + 'mail' => $this->usingMailFunction(), + 'null' => $this->usingNull(), + 'sendmail' => $this->usingSendmail(SendmailConfig::fromArray($config), $cancellation), + 'smtp' => $this->usingSmtp(SmtpConfig::fromArray($config)), + 'spool' => $this->usingSpool(SpoolConfig::fromArray($config)), + default => throw new InvalidArgumentException(sprintf( + 'Unsupported resolved email transport driver "%s".', + $driver, + )), + }; } } diff --git a/src/Email/Emailer.php b/src/Email/Emailer.php index c3da30a..35b4c96 100644 --- a/src/Email/Emailer.php +++ b/src/Email/Emailer.php @@ -8,6 +8,10 @@ use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer; +use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Email\Config\DkimConfig; use Infocyph\TalkingBytes\Email\Config\LogEmailConfig; use Infocyph\TalkingBytes\Email\Config\SendmailConfig; @@ -33,46 +37,66 @@ final readonly class Emailer { + private Clock $clock; + private EventDispatcher $events; - public function __construct(private EmailTransport $transport, ?EventDispatcher $events = null) - { + public function __construct( + private EmailTransport $transport, + ?EventDispatcher $events = null, + ?Clock $clock = null, + ) { $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); } - public static function fake(): self + public static function fake(?EventDispatcher $events = null, ?Clock $clock = null): self { - return new self(new FakeEmailTransport()); + return new self(new FakeEmailTransport(), $events, $clock); } - public static function usingLog(LogEmailConfig $config): self + public static function usingLog(LogEmailConfig $config, ?EventDispatcher $events = null, ?Clock $clock = null): self { - return new self(new LogEmailTransport($config)); + return new self(new LogEmailTransport($config), $events, $clock); } - public static function usingMailFunction(): self + public static function usingMailFunction(?EventDispatcher $events = null, ?Clock $clock = null): self { - return new self(new MailFunctionTransport()); + return new self(new MailFunctionTransport(), $events, $clock); } - public static function usingNull(): self + public static function usingNull(?EventDispatcher $events = null, ?Clock $clock = null): self { - return new self(new NullEmailTransport()); + return new self(new NullEmailTransport(), $events, $clock); } - public static function usingSendmail(SendmailConfig $config = new SendmailConfig()): self - { - return new self(new SendmailTransport($config)); + public static function usingSendmail( + SendmailConfig $config = new SendmailConfig(), + ?EventDispatcher $events = null, + ?Clock $clock = null, + ?CancellationSignal $cancellation = null, + ?Sleeper $sleeper = null, + ): self { + return new self( + new SendmailTransport( + $config, + cancellation: $cancellation, + clock: $clock, + sleeper: $sleeper, + ), + $events, + $clock, + ); } - public static function usingSmtp(SmtpConfig $config): self + public static function usingSmtp(SmtpConfig $config, ?EventDispatcher $events = null, ?Clock $clock = null): self { - return new self(new SmtpTransport($config)); + return new self(new SmtpTransport($config, clock: $clock), $events, $clock); } - public static function usingSpool(SpoolConfig $config): self + public static function usingSpool(SpoolConfig $config, ?EventDispatcher $events = null, ?Clock $clock = null): self { - return new self(new SpoolEmailTransport($config)); + return new self(new SpoolEmailTransport($config), $events, $clock); } public function assertable(): AssertableEmailTransport @@ -87,19 +111,18 @@ public function assertable(): AssertableEmailTransport public function send(EmailMessage $message): CommunicationResult { $this->events->dispatch('email.send.start', [ - 'subject' => $message->headersData()->subject, 'to_count' => count($message->envelope()->to), 'cc_count' => count($message->envelope()->cc), 'bcc_count' => count($message->envelope()->bcc), ]); - $startedAt = microtime(true); + $startedAt = $this->clock->monotonic(); $result = $this->transport->send($message->prepare()); $this->events->dispatch('email.send.finish', [ 'successful' => $result->successful, - 'error' => $result->error, - 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'failure_category' => $result->successful ? null : (ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error'), + 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), 'transport' => is_string($result->metadata['transport'] ?? null) ? $result->metadata['transport'] : null, @@ -116,7 +139,7 @@ public function transport(): EmailTransport public function withDkim(DkimConfig $config): self { - return new self(new DkimSigningTransport($this->transport, $config), $this->events); + return new self(new DkimSigningTransport($this->transport, $config), $this->events, $this->clock); } /** @@ -124,7 +147,7 @@ public function withDkim(DkimConfig $config): self */ public function withFallback(array $fallbackTransports): self { - return new self(new FallbackEmailTransport($this->transport, $fallbackTransports), $this->events); + return new self(new FallbackEmailTransport($this->transport, $fallbackTransports), $this->events, $this->clock); } /** @@ -132,7 +155,7 @@ public function withFallback(array $fallbackTransports): self */ public function withLogging(callable $logger): self { - return new self(new LoggingEmailTransport($this->transport, $logger), $this->events); + return new self(new LoggingEmailTransport($this->transport, $logger), $this->events, $this->clock); } public function withPsrLogger(object $logger, string $level = 'info'): self @@ -142,16 +165,20 @@ public function withPsrLogger(object $logger, string $level = 'info'): self public function withRateLimit(RateLimiter $rateLimiter): self { - return new self(new RateLimitedEmailTransport($this->transport, $rateLimiter), $this->events); + return new self(new RateLimitedEmailTransport($this->transport, $rateLimiter), $this->events, $this->clock); } - public function withRetry(RetryPolicy $retryPolicy): self + public function withRetry(RetryPolicy $retryPolicy, ?CancellationSignal $cancellation = null): self { - return new self(new RetryEmailTransport($this->transport, $retryPolicy), $this->events); + return new self( + new RetryEmailTransport($this->transport, $retryPolicy, $cancellation), + $this->events, + $this->clock, + ); } public function withTransport(EmailTransport $transport): self { - return new self($transport, $this->events); + return new self($transport, $this->events, $this->clock); } } diff --git a/src/Email/Mailbox/FakeMailboxTransport.php b/src/Email/Mailbox/FakeMailboxTransport.php index 86ae89e..ef6a533 100644 --- a/src/Email/Mailbox/FakeMailboxTransport.php +++ b/src/Email/Mailbox/FakeMailboxTransport.php @@ -4,11 +4,17 @@ namespace Infocyph\TalkingBytes\Email\Mailbox; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Email\Exception\MailboxProtocolException; use Infocyph\TalkingBytes\Email\Parser\HeaderParser; final class FakeMailboxTransport implements BodyStructureMailboxTransport, EnvelopeSummaryMailboxTransport, MailboxTransport, RawHeadersMailboxTransport, WatchableMailboxTransport { + private readonly Clock $clock; + + private readonly Sleeper $sleeper; + /** * @var array>> */ @@ -24,6 +30,12 @@ final class FakeMailboxTransport implements BodyStructureMailboxTransport, Envel */ private array $seen = ['INBOX' => []]; + public function __construct(?Clock $clock = null, ?Sleeper $sleeper = null) + { + $this->clock = $clock ?? Clock::system(); + $this->sleeper = $sleeper ?? Sleeper::system(); + } + public function addFlag(string $folder, int $uid, string $flag): void { $this->ensureMessageExists($folder, $uid); @@ -302,11 +314,11 @@ public function watch(string $folder, callable $onEvent, int $timeoutSeconds = 3 } $stop = $shouldStop ?? static fn(): bool => false; - $deadline = time() + max(1, $timeoutSeconds); + $deadline = $this->clock->monotonic() + max(1, $timeoutSeconds); - while (time() < $deadline && !$stop()) { + while ($this->clock->monotonic() < $deadline && !$stop()) { $onEvent(sprintf('* %d EXISTS', count($this->messages[$folder]))); - usleep(250000); + $this->sleeper->milliseconds(250); } } diff --git a/src/Email/Mailbox/ImapSocketTransport.php b/src/Email/Mailbox/ImapSocketTransport.php index b3c370f..bb009d8 100644 --- a/src/Email/Mailbox/ImapSocketTransport.php +++ b/src/Email/Mailbox/ImapSocketTransport.php @@ -4,6 +4,11 @@ namespace Infocyph\TalkingBytes\Email\Mailbox; +use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Email\Config\ImapConfig; use Infocyph\TalkingBytes\Email\Enum\ImapSecurity; use Infocyph\TalkingBytes\Email\Exception\MailboxAuthenticationException; @@ -12,6 +17,12 @@ final class ImapSocketTransport implements BodyStructureMailboxTransport, EnvelopeSummaryMailboxTransport, MailboxTransport, RawHeadersMailboxTransport, RawPartMailboxTransport, WatchableMailboxTransport { + private readonly Clock $clock; + + private readonly EventDispatcher $events; + + private readonly Sleeper $sleeper; + /** * @var list */ @@ -29,7 +40,14 @@ final class ImapSocketTransport implements BodyStructureMailboxTransport, Envelo public function __construct( private readonly ImapConfig $config, private readonly ImapResponseParser $responseParser = new ImapResponseParser(), - ) {} + ?EventDispatcher $events = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ) { + $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); + $this->sleeper = $sleeper ?? Sleeper::system(); + } public function __destruct() { @@ -69,14 +87,20 @@ public function connect(): void ); $this->selectedFolder = null; - $greeting = $this->readLine(); - if (!str_starts_with(strtoupper($greeting), '* OK')) { - throw new MailboxProtocolException(sprintf('Unexpected IMAP greeting: %s', trim($greeting))); - } + try { + $greeting = $this->readLine(); + if (!str_starts_with(strtoupper($greeting), '* OK')) { + throw new MailboxProtocolException(sprintf('Unexpected IMAP greeting: %s', trim($greeting))); + } - $this->refreshCapabilities(); - $this->negotiateStartTls(); - $this->login(); + $this->refreshCapabilities(); + $this->negotiateStartTls(); + $this->login(); + } catch (\Throwable $exception) { + $this->closeConnection(); + + throw $exception; + } } public function copy(string $folder, int $uid, string $targetFolder): void @@ -152,10 +176,7 @@ public function logout(): void // Best effort for shutdown. } - fclose($this->connection); - $this->connection = null; - $this->capabilities = []; - $this->selectedFolder = null; + $this->closeConnection(); } public function markSeen(string $folder, int $uid): void @@ -340,6 +361,17 @@ private function authenticateStatus(ImapResponse $response): void throw new MailboxAuthenticationException(implode("\n", $response->lines)); } + private function closeConnection(): void + { + if (is_resource($this->connection)) { + fclose($this->connection); + } + + $this->connection = null; + $this->capabilities = []; + $this->selectedFolder = null; + } + private function expectOk(ImapResponse $response, string $stage): ImapResponse { if ($response->isOk()) { @@ -461,10 +493,10 @@ private function readTaggedResponse(string $tag): ImapResponse $literals = []; $status = 'NO'; $totalBytes = 0; - $deadline = microtime(true) + $this->config->timeoutSeconds; + $deadline = $this->clock->monotonic() + $this->config->timeoutSeconds; while (true) { - if (microtime(true) >= $deadline) { + if ($this->clock->monotonic() >= $deadline) { throw new MailboxConnectionException('IMAP command deadline exceeded.'); } $line = $this->readLine(); @@ -523,7 +555,7 @@ private function runCommand(string $command): ImapResponse $start = SocketMailboxRuntime::dispatchStart('imap', $command, [ 'host' => $this->config->host, 'port' => $this->config->port, - ]); + ], $this->events, $this->clock); $imapCommand = new ImapCommand($this->nextTag(), $command); $this->write($imapCommand->line() . "\r\n"); @@ -535,6 +567,8 @@ private function runCommand(string $command): ImapResponse $response->status, $start['duration_ms'], ['host' => $this->config->host, 'port' => $this->config->port], + $this->events, + $this->clock, ); return $response; @@ -599,10 +633,10 @@ private function watchWithIdle(callable $onEvent, int $timeoutSeconds, callable throw new MailboxProtocolException(sprintf('IMAP IDLE was not accepted: %s', trim($continuation))); } - $deadline = time() + max(1, $timeoutSeconds); + $deadline = $this->clock->monotonic() + max(1, $timeoutSeconds); $socket = $this->requireConnection(); - while (time() < $deadline) { + while ($this->clock->monotonic() < $deadline) { if ($stop()) { break; } @@ -638,9 +672,9 @@ private function watchWithIdle(callable $onEvent, int $timeoutSeconds, callable */ private function watchWithNoopFallback(callable $onEvent, int $timeoutSeconds, callable $stop): void { - $deadline = time() + max(1, $timeoutSeconds); + $deadline = $this->clock->monotonic() + max(1, $timeoutSeconds); - while (time() < $deadline) { + while ($this->clock->monotonic() < $deadline) { if ($stop()) { return; } @@ -652,7 +686,7 @@ private function watchWithNoopFallback(callable $onEvent, int $timeoutSeconds, c } } - usleep(250000); + $this->sleeper->milliseconds(250); } } diff --git a/src/Email/Mailbox/Mailbox.php b/src/Email/Mailbox/Mailbox.php index 7f12c56..82bdf14 100644 --- a/src/Email/Mailbox/Mailbox.php +++ b/src/Email/Mailbox/Mailbox.php @@ -4,6 +4,10 @@ namespace Infocyph\TalkingBytes\Email\Mailbox; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Email\Config\ImapConfig; use Infocyph\TalkingBytes\Email\Parser\EmailParser; use Infocyph\TalkingBytes\Email\Parser\RawEmailParser; @@ -15,9 +19,13 @@ public function __construct( private EmailParser $parser = new RawEmailParser(), ) {} - public static function usingImap(ImapConfig $config): self - { - return new self(new ImapSocketTransport($config)); + public static function usingImap( + ImapConfig $config, + ?EventDispatcher $events = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ): self { + return new self(new ImapSocketTransport($config, events: $events, clock: $clock, sleeper: $sleeper)); } public function archive(string $sourceFolder, int $uid, ?string $archiveFolder = null): void @@ -55,6 +63,11 @@ public function archiveWithProviderStrategy(string $sourceFolder, int $uid, ?arr $this->archive($sourceFolder, $uid, $target); } + public function connect(): void + { + $this->transport->connect(); + } + public function createFolder(string $name): void { MailboxFolderNameGuard::assertValid($name); @@ -97,6 +110,11 @@ public function folders(): array return $this->transport->folders(); } + public function logout(): void + { + $this->transport->logout(); + } + public function noop(): void { $this->transport->noop(); @@ -142,4 +160,18 @@ public function watch(string $folder, callable $onEvent, int $timeoutSeconds = 3 MailboxFolderNameGuard::assertValid($folder); $this->folder($folder)->watch($onEvent, $timeoutSeconds, $shouldStop); } + + public function watchUntilCancelled( + string $folder, + callable $onEvent, + CancellationSignal $cancellation, + int $timeoutSeconds = 30, + ): void { + $this->watch( + $folder, + $onEvent, + $timeoutSeconds, + $cancellation->isRequested(...), + ); + } } diff --git a/src/Email/Mailbox/MailboxCommandRedactor.php b/src/Email/Mailbox/MailboxCommandRedactor.php index 9340cd3..9b81bb6 100644 --- a/src/Email/Mailbox/MailboxCommandRedactor.php +++ b/src/Email/Mailbox/MailboxCommandRedactor.php @@ -23,10 +23,8 @@ public static function redact(string $protocol, string $command): string private static function redactImap(string $command): string { - if (preg_match('/^\s*LOGIN\s+(.+?)\s+(.+)$/i', $command, $matches) === 1) { - $username = trim($matches[1]); - - return sprintf('LOGIN %s [REDACTED]', $username); + if (preg_match('/^\s*LOGIN\s+(.+?)\s+(.+)$/i', $command) === 1) { + return 'LOGIN [REDACTED] [REDACTED]'; } if (preg_match('/^\s*AUTHENTICATE\s+(.+)$/i', $command) === 1) { @@ -42,10 +40,12 @@ private static function redactPop3(string $command): string return 'PASS [REDACTED]'; } - if (preg_match('/^\s*APOP\s+(.+?)\s+(.+)$/i', $command, $matches) === 1) { - $username = trim($matches[1]); + if (preg_match('/^\s*USER\s+.+$/i', $command) === 1) { + return 'USER [REDACTED]'; + } - return sprintf('APOP %s [REDACTED]', $username); + if (preg_match('/^\s*APOP\s+(.+?)\s+(.+)$/i', $command) === 1) { + return 'APOP [REDACTED] [REDACTED]'; } if (preg_match('/^\s*AUTH\s+.+$/i', $command) === 1) { diff --git a/src/Email/Mailbox/Pop3Mailbox.php b/src/Email/Mailbox/Pop3Mailbox.php index 2d77a43..1caf1c5 100644 --- a/src/Email/Mailbox/Pop3Mailbox.php +++ b/src/Email/Mailbox/Pop3Mailbox.php @@ -4,6 +4,10 @@ namespace Infocyph\TalkingBytes\Email\Mailbox; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Email\Config\Pop3Config; use Infocyph\TalkingBytes\Email\Parser\EmailParser; use Infocyph\TalkingBytes\Email\Parser\RawEmailParser; @@ -16,9 +20,18 @@ public function __construct( private EmailParser $parser = new RawEmailParser(), ) {} - public static function usingConfig(Pop3Config $config): self + public static function usingConfig( + Pop3Config $config, + ?EventDispatcher $events = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ): self { + return new self(new Pop3SocketTransport($config, $events, $clock, $sleeper)); + } + + public function connect(): void { - return new self(new Pop3SocketTransport($config)); + $this->transport->connect(); } public function delete(int $messageNumber): void @@ -111,4 +124,24 @@ public function transport(): Pop3Transport { return $this->transport; } + + public function watch( + callable $onEvent, + int $timeoutSeconds = 30, + ?callable $shouldStop = null, + ): void { + $this->transport->watch($onEvent, $timeoutSeconds, $shouldStop); + } + + public function watchUntilCancelled( + callable $onEvent, + CancellationSignal $cancellation, + int $timeoutSeconds = 30, + ): void { + $this->watch( + $onEvent, + $timeoutSeconds, + $cancellation->isRequested(...), + ); + } } diff --git a/src/Email/Mailbox/Pop3SocketTransport.php b/src/Email/Mailbox/Pop3SocketTransport.php index 2b9bd73..54281a1 100644 --- a/src/Email/Mailbox/Pop3SocketTransport.php +++ b/src/Email/Mailbox/Pop3SocketTransport.php @@ -4,6 +4,11 @@ namespace Infocyph\TalkingBytes\Email\Mailbox; +use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Email\Config\Pop3Config; use Infocyph\TalkingBytes\Email\Enum\Pop3Security; use Infocyph\TalkingBytes\Email\Exception\MailboxAuthenticationException; @@ -12,6 +17,12 @@ final class Pop3SocketTransport implements Pop3Transport { + private readonly Clock $clock; + + private readonly EventDispatcher $events; + + private readonly Sleeper $sleeper; + /** * @var list */ @@ -22,7 +33,16 @@ final class Pop3SocketTransport implements Pop3Transport */ private mixed $connection = null; - public function __construct(private readonly Pop3Config $config) {} + public function __construct( + private readonly Pop3Config $config, + ?EventDispatcher $events = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ) { + $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); + $this->sleeper = $sleeper ?? Sleeper::system(); + } public function __destruct() { @@ -52,14 +72,20 @@ public function connect(): void $this->config->security === Pop3Security::Ssl, ); - $greeting = $this->readLine(); - if (!$this->isOkResponse($greeting)) { - throw new MailboxProtocolException(sprintf('Unexpected POP3 greeting: %s', trim($greeting))); - } + try { + $greeting = $this->readLine(); + if (!$this->isOkResponse($greeting)) { + throw new MailboxProtocolException(sprintf('Unexpected POP3 greeting: %s', trim($greeting))); + } - $this->refreshCapabilities(); - $this->negotiateStartTls(); - $this->login(); + $this->refreshCapabilities(); + $this->negotiateStartTls(); + $this->login(); + } catch (\Throwable $exception) { + $this->closeConnection(); + + throw $exception; + } } public function delete(int $messageNumber): void @@ -211,12 +237,12 @@ public function uidl(): array public function watch(callable $onEvent, int $timeoutSeconds = 30, ?callable $shouldStop = null): void { $stop = $shouldStop ?? static fn(): bool => false; - $deadline = time() + max(1, $timeoutSeconds); + $deadline = $this->clock->monotonic() + max(1, $timeoutSeconds); - while (time() < $deadline && !$stop()) { + while ($this->clock->monotonic() < $deadline && !$stop()) { $status = $this->status(); $onEvent(sprintf('+OK %d messages', $status->messages)); - usleep(250000); + $this->sleeper->milliseconds(250); } } @@ -300,10 +326,10 @@ private function readMultilineResponse(): array { $lines = []; $bytes = 0; - $deadline = microtime(true) + $this->config->timeoutSeconds; + $deadline = $this->clock->monotonic() + $this->config->timeoutSeconds; while (true) { - if (microtime(true) >= $deadline) { + if ($this->clock->monotonic() >= $deadline) { throw new MailboxConnectionException('POP3 command deadline exceeded.'); } $line = $this->readLine(); @@ -372,7 +398,7 @@ private function runSingleCommand(string $command): string $start = SocketMailboxRuntime::dispatchStart('pop3', $command, [ 'host' => $this->config->host, 'port' => $this->config->port, - ]); + ], $this->events, $this->clock); $this->write($command . "\r\n"); $response = $this->readLine(); SocketMailboxRuntime::dispatchFinish( @@ -381,6 +407,8 @@ private function runSingleCommand(string $command): string trim($response), $start['duration_ms'], ['host' => $this->config->host, 'port' => $this->config->port], + $this->events, + $this->clock, ); return $response; diff --git a/src/Email/Mailbox/SocketMailboxRuntime.php b/src/Email/Mailbox/SocketMailboxRuntime.php index 136ebfb..b8b938a 100644 --- a/src/Email/Mailbox/SocketMailboxRuntime.php +++ b/src/Email/Mailbox/SocketMailboxRuntime.php @@ -4,8 +4,10 @@ namespace Infocyph\TalkingBytes\Email\Mailbox; -use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Support\Clock; use Infocyph\TalkingBytes\Email\Exception\MailboxConnectionException; +use Throwable; final class SocketMailboxRuntime { @@ -60,14 +62,17 @@ public static function dispatchFinish( string $status, int $startedAtMs, array $endpoint, + ?EventDispatcher $events = null, + ?Clock $clock = null, ): void { - CommunicationEventBus::dispatch('mailbox.command.finish', [ + $runtimeClock = $clock ?? Clock::system(); + self::dispatch($events, 'mailbox.command.finish', [ 'protocol' => $protocol, 'host' => $endpoint['host'], 'port' => $endpoint['port'], 'command' => $command, 'status' => $status, - 'duration_ms' => (int) round((microtime(true) * 1000) - $startedAtMs), + 'duration_ms' => (int) round(($runtimeClock->monotonic() * 1000) - $startedAtMs), ]); } @@ -75,10 +80,16 @@ public static function dispatchFinish( * @param array{host:string,port:int} $endpoint * @return array{command:string,duration_ms:int} */ - public static function dispatchStart(string $protocol, string $command, array $endpoint): array - { + public static function dispatchStart( + string $protocol, + string $command, + array $endpoint, + ?EventDispatcher $events = null, + ?Clock $clock = null, + ): array { $redactedCommand = MailboxCommandRedactor::redact($protocol, $command); - CommunicationEventBus::dispatch('mailbox.command.start', [ + $runtimeClock = $clock ?? Clock::system(); + self::dispatch($events, 'mailbox.command.start', [ 'protocol' => $protocol, 'host' => $endpoint['host'], 'port' => $endpoint['port'], @@ -87,7 +98,7 @@ public static function dispatchStart(string $protocol, string $command, array $e return [ 'command' => $redactedCommand, - 'duration_ms' => (int) round(microtime(true) * 1000), + 'duration_ms' => (int) round($runtimeClock->monotonic() * 1000), ]; } @@ -180,4 +191,18 @@ public static function write(mixed $connection, string $value, string $protocol) $remaining = substr($remaining, $written); } } + + /** @param array $payload */ + private static function dispatch(?EventDispatcher $events, string $event, array $payload): void + { + if ($events === null) { + return; + } + + try { + $events->dispatch($event, $payload); + } catch (Throwable) { + // Observability must never affect mailbox protocol outcomes. + } + } } diff --git a/src/Email/Parser/BounceParser.php b/src/Email/Parser/BounceParser.php index 70972be..4577513 100644 --- a/src/Email/Parser/BounceParser.php +++ b/src/Email/Parser/BounceParser.php @@ -4,14 +4,23 @@ namespace Infocyph\TalkingBytes\Email\Parser; -use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus; +use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; use Infocyph\TalkingBytes\Email\Enum\BounceType; use Infocyph\TalkingBytes\Email\ValueObject\BounceReport; use Infocyph\TalkingBytes\Email\ValueObject\ParsedEmail; final readonly class BounceParser { - public function __construct(private DeliveryStatusParser $deliveryStatusParser = new DeliveryStatusParser()) {} + private EventDispatcher $events; + + public function __construct( + private DeliveryStatusParser $deliveryStatusParser = new DeliveryStatusParser(), + ?EventDispatcher $events = null, + ) { + $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + } public function parse(ParsedEmail $email): ?BounceReport { @@ -190,7 +199,7 @@ private function deliveryStatusFromParts(ParsedEmail $email): array private function dispatchDetectedEvent(BounceReport $report): void { - CommunicationEventBus::dispatch('bounce.detected', [ + $this->events->dispatch('bounce.detected', [ 'type' => $report->type->value, 'recipient' => $report->recipient, 'status' => $report->status, diff --git a/src/Email/Parser/CharsetDecoder.php b/src/Email/Parser/CharsetDecoder.php index a287975..155ec70 100644 --- a/src/Email/Parser/CharsetDecoder.php +++ b/src/Email/Parser/CharsetDecoder.php @@ -68,16 +68,12 @@ private function convertWithIconv(string $value, string $charset): ?string return null; } - $previous = set_error_handler(static fn(): bool => true); + set_error_handler(static fn(): bool => true); try { $converted = iconv($charset, 'UTF-8//IGNORE', $value); } finally { - if ($previous !== null) { - set_error_handler($previous); - } else { - restore_error_handler(); - } + restore_error_handler(); } return is_string($converted) ? $converted : null; diff --git a/src/Email/Receiver/SpoolEmailReceiver.php b/src/Email/Receiver/SpoolEmailReceiver.php index 160c15e..46f5e8a 100644 --- a/src/Email/Receiver/SpoolEmailReceiver.php +++ b/src/Email/Receiver/SpoolEmailReceiver.php @@ -5,7 +5,10 @@ namespace Infocyph\TalkingBytes\Email\Receiver; use FilesystemIterator; -use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus; +use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; +use Infocyph\TalkingBytes\Core\Support\Clock; use Infocyph\TalkingBytes\Email\Config\SpoolConfig; use Infocyph\TalkingBytes\Email\Parser\EmailParser; use Infocyph\TalkingBytes\Email\Parser\RawEmailParser; @@ -15,13 +18,21 @@ final readonly class SpoolEmailReceiver implements EmailReceiver { + private Clock $clock; + + private EventDispatcher $events; + public function __construct( private SpoolConfig $config, private EmailParser $parser = new RawEmailParser(), private bool $deleteAfterRead = false, private ?string $moveAfterRead = null, private ?string $failedDirectory = null, + ?EventDispatcher $events = null, + ?Clock $clock = null, ) { + $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); $directories = array_filter([ $this->config->directory, $this->config->processingDirectory, @@ -96,7 +107,7 @@ private function buildMetadata(string $originalPath, string $processingPath, boo 'path' => $originalPath, 'original_path' => $originalPath, 'processing_path' => $processingPath, - 'consumed_at' => $consume ? gmdate(DATE_ATOM) : null, + 'consumed_at' => $consume ? gmdate(DATE_ATOM, (int) floor($this->clock->timestamp())) : null, 'size_bytes' => filesize($processingPath) ?: 0, ]; } @@ -140,7 +151,7 @@ private function firstSpoolFile(): ?string } $extension = ltrim($this->config->extension, '.'); - $now = time(); + $now = (int) floor($this->clock->timestamp()); $candidate = null; foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $entry) { if (!$entry instanceof SplFileInfo) { @@ -255,10 +266,9 @@ private function readNext(bool $consume): ?ParsedEmail } $processingFile = $sourceFile; - $startedAt = microtime(true); - CommunicationEventBus::dispatch('email.receive.start', [ + $startedAt = $this->clock->monotonic(); + $this->events->dispatch('email.receive.start', [ 'source' => 'spool', - 'path' => $sourceFile, 'consume' => $consume, ]); @@ -267,12 +277,11 @@ private function readNext(bool $consume): ?ParsedEmail $raw = $this->readFile($processingFile, $consume); if ($raw === false) { $this->markFailed($processingFile, 'Unable to read spool file.'); - CommunicationEventBus::dispatch('email.receive.finish', [ + $this->events->dispatch('email.receive.finish', [ 'source' => 'spool', 'successful' => false, - 'path' => $processingFile, - 'error' => 'Unable to read spool file.', - 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'failure_category' => 'read_failure', + 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), ]); return null; @@ -281,17 +290,17 @@ private function readNext(bool $consume): ?ParsedEmail $parsed = $this->parseFile($raw, $this->buildMetadata($sourceFile, $processingFile, $consume)); } catch (\Throwable $exception) { $this->markFailed($processingFile, $exception->getMessage()); - CommunicationEventBus::dispatch('email.parse.failed', [ + $this->events->dispatch('email.parse.failed', [ 'source' => 'spool', - 'path' => $processingFile, - 'error' => $exception->getMessage(), + 'failure_category' => 'parse_failure', + 'exception_class' => $exception::class, ]); - CommunicationEventBus::dispatch('email.receive.finish', [ + $this->events->dispatch('email.receive.finish', [ 'source' => 'spool', 'successful' => false, - 'path' => $processingFile, - 'error' => $exception->getMessage(), - 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'failure_category' => 'parse_failure', + 'exception_class' => $exception::class, + 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), ]); return null; @@ -301,12 +310,10 @@ private function readNext(bool $consume): ?ParsedEmail $this->finalizeRead($processingFile); } - CommunicationEventBus::dispatch('email.receive.finish', [ + $this->events->dispatch('email.receive.finish', [ 'source' => 'spool', 'successful' => true, - 'path' => $processingFile, - 'subject' => $parsed->subject, - 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), ]); return $parsed; diff --git a/src/Email/System/SendmailProcess.php b/src/Email/System/SendmailProcess.php new file mode 100644 index 0000000..a867391 --- /dev/null +++ b/src/Email/System/SendmailProcess.php @@ -0,0 +1,348 @@ + $pipes + */ + private function __construct( + mixed $process, + private array $pipes, + private readonly float $deadline, + private readonly int $timeoutSeconds, + private readonly Clock $clock, + private readonly Sleeper $sleeper, + private readonly ?CancellationSignal $cancellation, + private readonly ?int $processGroupId, + ) { + $this->process = $process; + } + + public function __destruct() + { + $this->close(); + } + + /** + * @param list $command + */ + public static function start( + array $command, + int $timeoutSeconds, + ?CancellationSignal $cancellation = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ): self { + $runtimeClock = $clock ?? Clock::system(); + $runtimeSleeper = $sleeper ?? Sleeper::system(); + + if ($cancellation?->isRequested() === true) { + throw new RuntimeException('Sendmail process cancelled.'); + } + + $process = proc_open($command, self::descriptorSpec(), $pipes); + if (!is_resource($process)) { + throw new RuntimeException('Unable to open sendmail process.'); + } + + foreach ($pipes as $pipe) { + if (!is_resource($pipe) || stream_set_blocking($pipe, false)) { + continue; + } + + self::closePipeSet($pipes); + proc_terminate($process); + proc_close($process); + + throw new RuntimeException('Unable to configure sendmail process pipes.'); + } + + return new self( + $process, + $pipes, + $runtimeClock->monotonic() + $timeoutSeconds, + $timeoutSeconds, + $runtimeClock, + $runtimeSleeper, + $cancellation, + self::tryCreateProcessGroup($process), + ); + } + + public function close(): void + { + self::closePipeSet($this->pipes); + $this->pipes = []; + + if (!is_resource($this->process)) { + return; + } + + $process = $this->process; + $status = proc_get_status($process); + if ($status['running']) { + $this->terminate(); + } + + proc_close($process); + $this->process = null; + } + + public function finish(): SendmailProcessResult + { + $this->closeStdin(); + + while (true) { + $this->assertActive(); + $this->drainOutput(); + + $status = proc_get_status($this->requireProcess()); + if (!$status['running']) { + $this->drainOutput(); + + $exitCode = $status['exitcode']; + $closeCode = proc_close($this->requireProcess()); + $this->process = null; + self::closePipeSet($this->pipes); + $this->pipes = []; + + if ($closeCode >= 0) { + $exitCode = $closeCode; + } + + return new SendmailProcessResult($exitCode, $this->stdout, $this->stderr); + } + + $this->sleeper->milliseconds(self::POLL_DELAY_MS); + } + } + + public function write(string $chunk): void + { + if ($chunk === '') { + return; + } + + $stdin = $this->pipe(0); + $length = strlen($chunk); + $written = 0; + + while ($written < $length) { + $this->assertActive(); + $this->drainOutput(); + + $current = fwrite($stdin, substr($chunk, $written)); + if ($current === false) { + throw new RuntimeException('Unable to write email payload to sendmail process.'); + } + + if ($current === 0) { + $status = proc_get_status($this->requireProcess()); + if (!$status['running']) { + throw new RuntimeException('Sendmail process exited while receiving the email payload.'); + } + + $this->sleeper->milliseconds(self::POLL_DELAY_MS); + + continue; + } + + $written += $current; + } + } + + /** + * @param array $pipes + */ + private static function closePipeSet(array $pipes): void + { + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + } + + /** + * @return array + */ + private static function descriptorSpec(): array + { + return [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + } + + /** + * @param resource $process + */ + private static function tryCreateProcessGroup(mixed $process): ?int + { + if ( + !function_exists('posix_setpgid') + || !function_exists('posix_getpgid') + || !function_exists('posix_kill') + ) { + return null; + } + + $status = proc_get_status($process); + $pid = $status['pid']; + if ($pid < 1) { + return null; + } + + try { + if (!posix_setpgid($pid, $pid)) { + return null; + } + + return posix_getpgid($pid) === $pid ? $pid : null; + } catch (Throwable) { + return null; + } + } + + private function appendDiagnostic(string $buffer, string|false $chunk): string + { + if (!is_string($chunk) || $chunk === '' || strlen($buffer) >= self::MAX_DIAGNOSTIC_BYTES) { + return $buffer; + } + + return $buffer . substr($chunk, 0, self::MAX_DIAGNOSTIC_BYTES - strlen($buffer)); + } + + private function assertActive(): void + { + if ($this->cancellation?->isRequested() === true) { + $this->terminate(); + + throw new RuntimeException('Sendmail process cancelled.'); + } + + if ($this->clock->monotonic() >= $this->deadline) { + $this->terminate(); + + throw new RuntimeException(sprintf( + 'Sendmail process timed out after %d seconds.', + $this->timeoutSeconds, + )); + } + } + + private function closeStdin(): void + { + $stdin = $this->pipes[0] ?? null; + if (is_resource($stdin)) { + fclose($stdin); + } + + $this->pipes[0] = null; + } + + private function drainOutput(): void + { + $stdout = $this->pipes[1] ?? null; + if (is_resource($stdout)) { + $this->stdout = $this->appendDiagnostic($this->stdout, stream_get_contents($stdout)); + } + + $stderr = $this->pipes[2] ?? null; + if (is_resource($stderr)) { + $this->stderr = $this->appendDiagnostic($this->stderr, stream_get_contents($stderr)); + } + } + + /** + * @return resource + */ + private function pipe(int $index): mixed + { + $pipe = $this->pipes[$index] ?? null; + if (!is_resource($pipe)) { + throw new RuntimeException('Sendmail process pipe is not available.'); + } + + return $pipe; + } + + /** + * @return resource + */ + private function requireProcess(): mixed + { + if (!is_resource($this->process)) { + throw new RuntimeException('Sendmail process is not available.'); + } + + return $this->process; + } + + private function signal(int $signal): void + { + if ($this->processGroupId !== null && function_exists('posix_kill')) { + try { + if (posix_kill(-$this->processGroupId, $signal)) { + return; + } + } catch (Throwable) { + // Fall through to portable direct-child termination. + } + } + + if (is_resource($this->process)) { + proc_terminate($this->process, $signal); + } + } + + private function terminate(): void + { + if (!is_resource($this->process)) { + return; + } + + $process = $this->process; + $status = proc_get_status($process); + if (!$status['running']) { + return; + } + + $this->signal(self::GRACEFUL_SIGNAL); + $this->sleeper->milliseconds(self::TERMINATION_GRACE_MS); + + $status = proc_get_status($process); + if ($status['running']) { + $this->signal(self::FORCE_SIGNAL); + } + } +} diff --git a/src/Email/System/SendmailProcessResult.php b/src/Email/System/SendmailProcessResult.php new file mode 100644 index 0000000..bbf969a --- /dev/null +++ b/src/Email/System/SendmailProcessResult.php @@ -0,0 +1,14 @@ + count($message->envelope()->to), 'cc_count' => count($message->envelope()->cc), 'bcc_count' => count($message->envelope()->bcc), - 'subject' => $message->headersData()->subject, ]); try { @@ -32,18 +32,13 @@ public function send(EmailMessage $message): CommunicationResult } catch (Throwable $throwable) { ($this->logger)('email.send.finish', [ 'successful' => false, - 'error' => $throwable->getMessage(), - 'metadata' => [], + ...ObservabilitySanitizer::throwableContext($throwable), ]); throw $throwable; } - ($this->logger)('email.send.finish', [ - 'successful' => $result->successful, - 'error' => $result->error, - 'metadata' => $result->metadata, - ]); + ($this->logger)('email.send.finish', ObservabilitySanitizer::resultContext($result)); return $result; } diff --git a/src/Email/Transport/RetryEmailTransport.php b/src/Email/Transport/RetryEmailTransport.php index b902fa5..cbfc80f 100644 --- a/src/Email/Transport/RetryEmailTransport.php +++ b/src/Email/Transport/RetryEmailTransport.php @@ -5,6 +5,7 @@ namespace Infocyph\TalkingBytes\Email\Transport; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; use Infocyph\TalkingBytes\Core\Support\RetryExecutor; use Infocyph\TalkingBytes\Email\EmailMessage; use Infocyph\TalkingBytes\Retry\RetryPolicy; @@ -14,10 +15,15 @@ public function __construct( private EmailTransport $innerTransport, private RetryPolicy $retryPolicy, + private ?CancellationSignal $cancellation = null, ) {} public function send(EmailMessage $message): CommunicationResult { - return RetryExecutor::run($this->retryPolicy, fn(): CommunicationResult => $this->innerTransport->send($message)); + return RetryExecutor::run( + $this->retryPolicy, + fn(): CommunicationResult => $this->innerTransport->send($message), + cancellation: $this->cancellation, + ); } } diff --git a/src/Email/Transport/SendmailTransport.php b/src/Email/Transport/SendmailTransport.php index 87ff507..8dbca4e 100644 --- a/src/Email/Transport/SendmailTransport.php +++ b/src/Email/Transport/SendmailTransport.php @@ -5,21 +5,33 @@ namespace Infocyph\TalkingBytes\Email\Transport; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Email\Config\SendmailConfig; use Infocyph\TalkingBytes\Email\EmailMessage; use Infocyph\TalkingBytes\Email\System\EmailHeaderBuilder; use Infocyph\TalkingBytes\Email\System\RawEmailBuilder; +use Infocyph\TalkingBytes\Email\System\SendmailProcess; use RuntimeException; final readonly class SendmailTransport implements EmailTransport { - private const int MAX_DIAGNOSTIC_BYTES = 65_536; + private Clock $clock; + + private Sleeper $sleeper; public function __construct( private SendmailConfig $config = new SendmailConfig(), private RawEmailBuilder $rawEmailBuilder = new RawEmailBuilder(), private EmailHeaderBuilder $headerBuilder = new EmailHeaderBuilder(), - ) {} + private ?CancellationSignal $cancellation = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ) { + $this->clock = $clock ?? Clock::system(); + $this->sleeper = $sleeper ?? Sleeper::system(); + } public function send(EmailMessage $message): CommunicationResult { @@ -46,15 +58,6 @@ public function send(EmailMessage $message): CommunicationResult return EmailTransportResultFactory::success('sendmail', $messageId, $recipients, ['size_bytes' => $sizeBytes]); } - private function appendDiagnostic(string $buffer, string|false $chunk): string - { - if (!is_string($chunk) || $chunk === '' || strlen($buffer) >= self::MAX_DIAGNOSTIC_BYTES) { - return $buffer; - } - - return $buffer . substr($chunk, 0, self::MAX_DIAGNOSTIC_BYTES - strlen($buffer)); - } - /** * @return list */ @@ -70,165 +73,38 @@ private function buildCommand(EmailMessage $message): array return $command; } - /** - * @param array $pipes - */ - private function closePipes(array $pipes): void - { - foreach ($pipes as $pipe) { - if (!is_resource($pipe)) { - continue; - } - - fclose($pipe); - } - } - - /** - * @param resource $process - */ - private function closeProcess($process, bool $alreadyClosed): void - { - if ($alreadyClosed || !is_resource($process)) { - return; - } - - proc_terminate($process); - proc_close($process); - } - - /** - * @return array - */ - private function descriptorSpec(): array - { - return [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - } - private function executeSendmail(EmailMessage $message): int { - $process = proc_open($this->buildCommand($message), $this->descriptorSpec(), $pipes); - - if (!is_resource($process)) { - throw new RuntimeException('Unable to open sendmail process.'); - } - - $processClosed = false; - $sizeBytes = 0; + $process = SendmailProcess::start( + $this->buildCommand($message), + $this->config->timeoutSeconds, + $this->cancellation, + $this->clock, + $this->sleeper, + ); try { - $sizeBytes = $this->writeToStdin($pipes[0], $message); - fclose($pipes[0]); - - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - - [$exitCode, $stdout, $stderr] = $this->readProcessOutputUntilExit( - $process, - $pipes[1], - $pipes[2], - $this->config->timeoutSeconds, + $sizeBytes = $this->rawEmailBuilder->buildToStream( + $message, + $process->write(...), + includeSubject: true, + maxBytes: $this->config->maxMessageBytes, ); - $processClosed = true; + $result = $process->finish(); } finally { - $this->closePipes($pipes); - $this->closeProcess($process, $processClosed); + $process->close(); } - if ($exitCode !== 0) { - $detail = trim(($stderr ?: $stdout) ?: 'unknown error'); + if ($result->exitCode !== 0) { + $detail = trim(($result->stderr ?: $result->stdout) ?: 'unknown error'); - throw new RuntimeException(sprintf('Sendmail exited with code %d: %s', $exitCode, $detail)); + throw new RuntimeException(sprintf( + 'Sendmail exited with code %d: %s', + $result->exitCode, + $detail, + )); } return $sizeBytes; } - - /** - * @param resource $process - * @param resource $stdout - * @param resource $stderr - * @return array{0:int,1:string,2:string} - */ - private function readProcessOutputUntilExit($process, $stdout, $stderr, int $timeoutSeconds): array - { - $start = microtime(true); - $stdoutBuffer = ''; - $stderrBuffer = ''; - $terminated = false; - - while (true) { - $status = proc_get_status($process); - $stdoutBuffer = $this->appendDiagnostic($stdoutBuffer, stream_get_contents($stdout)); - $stderrBuffer = $this->appendDiagnostic($stderrBuffer, stream_get_contents($stderr)); - - if (!$status['running']) { - break; - } - - if ((microtime(true) - $start) > $timeoutSeconds) { - $terminated = true; - proc_terminate($process); - usleep(100000); - - $statusAfterGrace = proc_get_status($process); - if ($statusAfterGrace['running']) { - proc_terminate($process, 9); - } - - break; - } - - usleep(10000); - } - - $stdoutBuffer = $this->appendDiagnostic($stdoutBuffer, stream_get_contents($stdout)); - $stderrBuffer = $this->appendDiagnostic($stderrBuffer, stream_get_contents($stderr)); - - $exitCode = proc_close($process); - - if ($terminated) { - throw new RuntimeException(sprintf('Sendmail process timed out after %d seconds.', $timeoutSeconds)); - } - - return [$exitCode, $stdoutBuffer, $stderrBuffer]; - } - - /** - * @param resource $stdin - */ - private function writeChunk($stdin, string $chunk): void - { - $length = strlen($chunk); - $written = 0; - - while ($written < $length) { - $current = fwrite($stdin, substr($chunk, $written)); - - if ($current === false || $current === 0) { - throw new RuntimeException('Unable to write email payload to sendmail process.'); - } - - $written += $current; - } - } - - /** - * @param resource $stdin - */ - private function writeToStdin($stdin, EmailMessage $message): int - { - return $this->rawEmailBuilder->buildToStream( - $message, - function (string $chunk) use ($stdin): void { - $this->writeChunk($stdin, $chunk); - }, - includeSubject: true, - maxBytes: $this->config->maxMessageBytes, - ); - } } diff --git a/src/Email/Transport/SmtpTransport.php b/src/Email/Transport/SmtpTransport.php index 7708496..8bca8f3 100644 --- a/src/Email/Transport/SmtpTransport.php +++ b/src/Email/Transport/SmtpTransport.php @@ -5,6 +5,7 @@ namespace Infocyph\TalkingBytes\Email\Transport; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\Clock; use Infocyph\TalkingBytes\Email\Config\SmtpConfig; use Infocyph\TalkingBytes\Email\EmailMessage; use Infocyph\TalkingBytes\Email\Enum\SmtpAuthMechanism; @@ -22,13 +23,18 @@ final readonly class SmtpTransport implements EmailTransport { + private Clock $clock; + public function __construct( private SmtpConfig $config, private RawEmailBuilder $rawEmailBuilder = new RawEmailBuilder(), private SmtpCapabilityParser $capabilityParser = new SmtpCapabilityParser(), private ?SmtpEnvelopePlanner $envelopePlanner = null, private SmtpTlsContext $tlsContext = new SmtpTlsContext(), - ) {} + ?Clock $clock = null, + ) { + $this->clock = $clock ?? Clock::system(); + } public function send(EmailMessage $message): CommunicationResult { @@ -37,7 +43,7 @@ public function send(EmailMessage $message): CommunicationResult $connection = null; $messageStream = null; $capabilities = new SmtpCapabilities(); - $start = microtime(true); + $start = $this->clock->monotonic(); $serverGreeting = null; $authMechanism = null; $sessionStarted = false; @@ -194,7 +200,7 @@ private function buildRuntimeMetadata( 'smtp_port' => $this->config->port, 'security' => $this->config->security->value, 'ehlo_capabilities' => array_keys($capabilities->values), - 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), 'message_id' => $messageId, 'auth_mechanism' => $authMechanism, 'server_greeting' => $serverGreeting !== null ? trim($serverGreeting) : null, @@ -339,10 +345,10 @@ private function readResponse($connection, array &$transcript = []): array $response = ''; $lines = []; $code = 0; - $deadline = microtime(true) + $this->config->timeoutSeconds; + $deadline = $this->clock->monotonic() + $this->config->timeoutSeconds; while (true) { - if (microtime(true) >= $deadline) { + if ($this->clock->monotonic() >= $deadline) { throw new RuntimeException('SMTP command deadline exceeded.'); } diff --git a/src/Grpc/GrpcClient.php b/src/Grpc/GrpcClient.php index 8c41ad5..250be53 100644 --- a/src/Grpc/GrpcClient.php +++ b/src/Grpc/GrpcClient.php @@ -8,8 +8,12 @@ use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer; use Infocyph\TalkingBytes\Grpc\Contract\GrpcMiddleware; use Infocyph\TalkingBytes\Grpc\Middleware\RetryMiddleware; +use Infocyph\TalkingBytes\Grpc\Native\GeneratedStubGrpcInvoker; use Infocyph\TalkingBytes\Grpc\Native\NativeGrpcInvoker; use Infocyph\TalkingBytes\Grpc\Native\NativeGrpcResult; use Infocyph\TalkingBytes\Grpc\Native\NativeGrpcStreamingInvoker; @@ -24,6 +28,8 @@ final readonly class GrpcClient { + private Clock $clock; + private EventDispatcher $events; private GrpcPipeline $pipeline; @@ -36,21 +42,41 @@ private function __construct( private array $middlewares = [], private ?NativeGrpcStreamingInvoker $streamingInvoker = null, ?EventDispatcher $events = null, + ?Clock $clock = null, ) { $this->pipeline = new GrpcPipeline($transport, $middlewares); $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); } /** * @param callable(GrpcRequest): GrpcResponse $caller */ - public static function using(callable $caller, ?EventDispatcher $events = null): self + public static function using(callable $caller, ?EventDispatcher $events = null, ?Clock $clock = null): self { - return new self(new GrpcTransport($caller, $events), events: $events); + return new self(new GrpcTransport($caller, $events, $clock), events: $events, clock: $clock); } - public static function usingNative(NativeGrpcInvoker $invoker): self - { + /** + * @param array $methodMap + */ + public static function usingGeneratedStub( + object $stubClient, + array $methodMap = [], + ?EventDispatcher $events = null, + ?CancellationSignal $cancellation = null, + ?Clock $clock = null, + ): self { + $invoker = new GeneratedStubGrpcInvoker($stubClient, $methodMap, $cancellation); + + return self::usingNativeStreaming($invoker, $invoker, $events, $clock); + } + + public static function usingNative( + NativeGrpcInvoker $invoker, + ?EventDispatcher $events = null, + ?Clock $clock = null, + ): self { return self::using( static function (GrpcRequest $request) use ($invoker): GrpcResponse { $native = $invoker->invoke( @@ -68,14 +94,23 @@ static function (GrpcRequest $request) use ($invoker): GrpcResponse { metadata: $native->metadata, ); }, + $events, + $clock, ); } public static function usingNativeStreaming( NativeGrpcInvoker $invoker, NativeGrpcStreamingInvoker $streamingInvoker, + ?EventDispatcher $events = null, + ?Clock $clock = null, ): self { - return new self(self::usingNative($invoker)->transport, streamingInvoker: $streamingInvoker); + return new self( + self::usingNative($invoker, $events, $clock)->transport, + streamingInvoker: $streamingInvoker, + events: $events, + clock: $clock, + ); } /** @@ -135,9 +170,11 @@ public function supportsStreaming(): bool return $this->streamingInvoker !== null; } - public function withGrpcRetry(?GrpcRetryPolicy $policy = null): self - { - return $this->withRetryPolicy($policy ?? GrpcRetryPolicy::standard()); + public function withGrpcRetry( + ?GrpcRetryPolicy $policy = null, + ?CancellationSignal $cancellation = null, + ): self { + return $this->withRetryPolicy($policy ?? GrpcRetryPolicy::standard(), $cancellation); } public function withMiddleware(GrpcMiddleware $middleware): self @@ -145,7 +182,7 @@ public function withMiddleware(GrpcMiddleware $middleware): self $middlewares = $this->middlewares; $middlewares[] = $middleware; - return new self($this->transport, $middlewares, $this->streamingInvoker, $this->events); + return new self($this->transport, $middlewares, $this->streamingInvoker, $this->events, $this->clock); } /** @@ -153,12 +190,12 @@ public function withMiddleware(GrpcMiddleware $middleware): self */ public function withMiddlewares(array $middlewares): self { - return new self($this->transport, $middlewares, $this->streamingInvoker, $this->events); + return new self($this->transport, $middlewares, $this->streamingInvoker, $this->events, $this->clock); } - public function withRetryPolicy(RetryPolicy $policy): self + public function withRetryPolicy(RetryPolicy $policy, ?CancellationSignal $cancellation = null): self { - return $this->withMiddleware(new RetryMiddleware($policy)); + return $this->withMiddleware(new RetryMiddleware($policy, $cancellation)); } /** @@ -210,7 +247,7 @@ private function runStream(string $streamType, string $method, callable $execute ); } - $startedAt = microtime(true); + $startedAt = $this->clock->monotonic(); $this->events->dispatch('grpc.stream.start', [ 'transport' => 'grpc', 'type' => $streamType, @@ -220,13 +257,13 @@ private function runStream(string $streamType, string $method, callable $execute try { $native = $execute($this->streamingInvoker); } catch (Throwable $exception) { - $durationMs = (int) ((microtime(true) - $startedAt) * 1000); + $durationMs = (int) (($this->clock->monotonic() - $startedAt) * 1000); $this->events->dispatch('grpc.stream.failed', [ 'transport' => 'grpc', 'type' => $streamType, 'method' => $method, 'duration_ms' => $durationMs, - 'error' => $exception->getMessage(), + ...ObservabilitySanitizer::throwableContext($exception), ]); $error = new GrpcCallError( @@ -254,7 +291,7 @@ private function runStream(string $streamType, string $method, callable $execute ); } - $durationMs = (int) ((microtime(true) - $startedAt) * 1000); + $durationMs = (int) (($this->clock->monotonic() - $startedAt) * 1000); $response = new GrpcResponse( status: GrpcStatus::fromCode($native->statusCode), message: $native->message, diff --git a/src/Grpc/GrpcClientFactory.php b/src/Grpc/GrpcClientFactory.php new file mode 100644 index 0000000..9bc8266 --- /dev/null +++ b/src/Grpc/GrpcClientFactory.php @@ -0,0 +1,191 @@ + $config + */ + public function using(callable $caller, array $config = []): GrpcClient + { + return $this->applyResolvedConfig( + GrpcClient::using($caller, $this->events, $this->clock), + $config, + ); + } + + /** + * @param array $methodMap + * @param array $config + */ + public function usingGeneratedStub( + object $stubClient, + array $methodMap = [], + array $config = [], + ): GrpcClient { + return $this->applyResolvedConfig( + GrpcClient::usingGeneratedStub( + $stubClient, + $methodMap, + $this->events, + $this->cancellation, + $this->clock, + ), + $config, + ); + } + + /** + * @param array $config + */ + public function usingNative( + NativeGrpcInvoker $invoker, + ?NativeGrpcStreamingInvoker $streamingInvoker = null, + array $config = [], + ): GrpcClient { + $client = $streamingInvoker instanceof NativeGrpcStreamingInvoker + ? GrpcClient::usingNativeStreaming($invoker, $streamingInvoker, $this->events, $this->clock) + : GrpcClient::usingNative($invoker, $this->events, $this->clock); + + return $this->applyResolvedConfig($client, $config); + } + + /** @param array $config */ + private static function bool(array $config, string $key, bool $default): bool + { + if (!array_key_exists($key, $config)) { + return $default; + } + + $value = $config[$key]; + if (is_bool($value)) { + return $value; + } + + if (is_int($value) && ($value === 0 || $value === 1)) { + return $value === 1; + } + + if (is_string($value)) { + $parsed = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + if (is_bool($parsed)) { + return $parsed; + } + } + + throw new InvalidArgumentException(sprintf('gRPC resolved configuration key "%s" must be a boolean.', $key)); + } + + /** @param array $config */ + private static function float(array $config, string $key, float $default): float + { + $value = $config[$key] ?? $default; + if (is_float($value) || is_int($value) || (is_string($value) && is_numeric($value))) { + return (float) $value; + } + + throw new InvalidArgumentException(sprintf('gRPC resolved configuration key "%s" must be numeric.', $key)); + } + + /** @param array $config */ + private static function int(array $config, string $key, int $default): int + { + $value = $config[$key] ?? $default; + if (is_int($value)) { + return $value; + } + + if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) { + $parsed = filter_var($value, FILTER_VALIDATE_INT); + if (is_int($parsed)) { + return $parsed; + } + } + + throw new InvalidArgumentException(sprintf('gRPC resolved configuration key "%s" must be an integer.', $key)); + } + + private static function nullableInt(mixed $value, string $key): ?int + { + if ($value === null || $value === '') { + return null; + } + + if (is_int($value)) { + return $value; + } + + if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) { + $parsed = filter_var($value, FILTER_VALIDATE_INT); + if (is_int($parsed)) { + return $parsed; + } + } + + throw new InvalidArgumentException(sprintf('gRPC resolved configuration key "%s" must be an integer or null.', $key)); + } + + /** + * @param array $config + * @return array + */ + private static function section(array $config, string $key): array + { + $value = $config[$key] ?? []; + if (!is_array($value)) { + throw new InvalidArgumentException(sprintf('gRPC resolved configuration section "%s" must be an array.', $key)); + } + + $section = []; + foreach ($value as $name => $item) { + if (is_string($name)) { + $section[$name] = $item; + } + } + + return $section; + } + + /** + * @param array $config + */ + private function applyResolvedConfig(GrpcClient $client, array $config): GrpcClient + { + $retry = self::section($config, 'retry'); + if (!self::bool($retry, 'enabled', false)) { + return $client; + } + + $maxDelay = $retry['max_delay_ms'] ?? null; + + return $client->withGrpcRetry( + GrpcRetryPolicy::standard( + self::int($retry, 'attempts', 3), + self::int($retry, 'base_delay_ms', 100), + self::nullableInt($maxDelay, 'max_delay_ms'), + self::float($retry, 'jitter_ratio', 0.0), + ), + $this->cancellation, + ); + } +} diff --git a/src/Grpc/GrpcInboundDispatcher.php b/src/Grpc/GrpcInboundDispatcher.php index c2b0730..414363d 100644 --- a/src/Grpc/GrpcInboundDispatcher.php +++ b/src/Grpc/GrpcInboundDispatcher.php @@ -7,14 +7,19 @@ use Closure; use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher; use Infocyph\TalkingBytes\Core\Event\EventDispatcher; -use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundExchange; use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundHandlerInterface; use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundRequest; use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundResponse; +use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundSource; use Throwable; final class GrpcInboundDispatcher { + private readonly Clock $clock; + private readonly EventDispatcher $events; /** @@ -25,15 +30,19 @@ final class GrpcInboundDispatcher /** * @param array $handlers */ - public function __construct(array $handlers = [], ?EventDispatcher $events = null) - { + public function __construct( + array $handlers = [], + ?EventDispatcher $events = null, + ?Clock $clock = null, + ) { $normalized = []; foreach ($handlers as $method => $handler) { $normalized[GrpcMethodGuard::normalize($method)] = Closure::fromCallable($handler); } $this->handlers = $normalized; - $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); + $this->events = BestEffortEventDispatcher::wrap($events); } public static function new(): self @@ -43,7 +52,7 @@ public static function new(): self public function handle(GrpcInboundRequest $request): GrpcInboundResponse { - $startedAt = microtime(true); + $startedAt = $this->clock->monotonic(); $this->events->dispatch('grpc.inbound.start', [ 'method' => $request->method, 'deadline_seconds' => $request->deadlineSeconds, @@ -58,7 +67,7 @@ public function handle(GrpcInboundRequest $request): GrpcInboundResponse 'method' => $request->method, 'status_code' => $response->status->value, 'status_name' => $response->status->name, - 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000), + 'duration_ms' => (int) (($this->clock->monotonic() - $startedAt) * 1000), ]); return $response; @@ -67,27 +76,21 @@ public function handle(GrpcInboundRequest $request): GrpcInboundResponse try { $response = $handler($request); } catch (Throwable $exception) { - $durationMs = (int) ((microtime(true) - $startedAt) * 1000); + $durationMs = (int) (($this->clock->monotonic() - $startedAt) * 1000); $this->events->dispatch('grpc.inbound.failed', [ 'method' => $request->method, 'duration_ms' => $durationMs, 'exception' => $exception::class, ]); - return new GrpcInboundResponse( - status: GrpcStatus::Internal, - message: 'Inbound gRPC handler failed.', - metadata: [ - 'exception' => $exception::class, - ], - ); + return GrpcInboundResponse::internal(); } $this->events->dispatch('grpc.inbound.finish', [ 'method' => $request->method, 'status_code' => $response->status->value, 'status_name' => $response->status->name, - 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000), + 'duration_ms' => (int) (($this->clock->monotonic() - $startedAt) * 1000), ]); return $response; @@ -102,6 +105,22 @@ public function receive( return $this->handle(new GrpcInboundRequest($method, $message, $headers, $deadlineSeconds)); } + public function serveOne( + GrpcInboundSource $source, + ?CancellationSignal $cancellation = null, + ): bool { + if ($cancellation?->isRequested() === true) { + return false; + } + + $exchange = $source->accept($cancellation); + if ($exchange === null) { + return false; + } + + return $this->completeAcceptedExchange($exchange, $cancellation); + } + /** * @param callable(GrpcInboundRequest):GrpcInboundResponse|GrpcInboundHandlerInterface $handler */ @@ -114,6 +133,21 @@ public function withHandler(string $method, callable|GrpcInboundHandlerInterface ? $handler->handle(...) : Closure::fromCallable($handler); - return new self($handlers, $this->events); + return new self($handlers, $this->events, $this->clock); + } + + private function completeAcceptedExchange( + GrpcInboundExchange $exchange, + ?CancellationSignal $cancellation, + ): bool { + if ($cancellation?->isRequested() === true) { + $exchange->complete(GrpcInboundResponse::cancelled()); + + return true; + } + + $exchange->complete($this->handle($exchange->request())); + + return true; } } diff --git a/src/Grpc/Middleware/RetryMiddleware.php b/src/Grpc/Middleware/RetryMiddleware.php index cefb284..43cdcc5 100644 --- a/src/Grpc/Middleware/RetryMiddleware.php +++ b/src/Grpc/Middleware/RetryMiddleware.php @@ -6,6 +6,7 @@ use Closure; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; use Infocyph\TalkingBytes\Core\Support\Clock; use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Grpc\Contract\GrpcMiddleware; @@ -21,6 +22,7 @@ public function __construct( private RetryPolicy $policy, + private ?CancellationSignal $cancellation = null, ?Clock $clock = null, ?Sleeper $sleeper = null, ) { @@ -37,6 +39,10 @@ public function handle(GrpcRequest $request, Closure $next): CommunicationResult $startedAt = $this->clock->monotonic(); $attempt = 1; while (true) { + if ($this->cancellation?->isRequested() === true) { + return $this->cancelled($attempt - 1); + } + $attemptRequest = $this->withRemainingDeadline($request, $startedAt); $result = $next($attemptRequest); $decision = $this->policy->decide(new RetryContext($attempt, $result)); @@ -44,11 +50,27 @@ public function handle(GrpcRequest $request, Closure $next): CommunicationResult return $result; } - $this->sleeper->milliseconds($decision->delayMs); + if ($this->cancellation === null) { + $this->sleeper->milliseconds($decision->delayMs); + } elseif (!$this->sleeper->millisecondsInterruptibly($decision->delayMs, $this->cancellation)) { + return $this->cancelled($attempt); + } $attempt++; } } + private function cancelled(int $attempts): CommunicationResult + { + return CommunicationResult::failure( + 'gRPC operation cancelled.', + metadata: [ + 'cancelled' => true, + 'attempts' => max(0, $attempts), + 'transport' => 'grpc', + ], + ); + } + private function delayFitsDeadline(GrpcRequest $request, float $startedAt, int $delayMs): bool { if ($request->deadlineSeconds === null) { diff --git a/src/Grpc/Native/GeneratedStubGrpcInvoker.php b/src/Grpc/Native/GeneratedStubGrpcInvoker.php index 2ceff7c..8bf38b0 100644 --- a/src/Grpc/Native/GeneratedStubGrpcInvoker.php +++ b/src/Grpc/Native/GeneratedStubGrpcInvoker.php @@ -4,19 +4,47 @@ namespace Infocyph\TalkingBytes\Grpc\Native; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; use Infocyph\TalkingBytes\Grpc\GrpcDeadline; use Infocyph\TalkingBytes\Grpc\GrpcMetadata; +use Infocyph\TalkingBytes\Grpc\GrpcMethodGuard; use Infocyph\TalkingBytes\Grpc\GrpcStatus; +use InvalidArgumentException; +use ReflectionMethod; +use ReflectionObject; +use RuntimeException; +use Throwable; final readonly class GeneratedStubGrpcInvoker implements NativeGrpcInvoker, NativeGrpcStreamingInvoker { + /** @var array */ + private array $methodMap; + + /** @var array */ + private array $streamOpenArity; + /** * @param array $methodMap Maps gRPC method path to stub method name. */ public function __construct( private object $stubClient, - private array $methodMap = [], - ) {} + array $methodMap = [], + private ?CancellationSignal $cancellation = null, + ) { + $reflection = new ReflectionObject($this->stubClient); + $this->methodMap = $this->normalizeMethodMap($reflection, $methodMap); + + $streamOpenArity = []; + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isStatic()) { + continue; + } + + $streamOpenArity[$method->getName()] = self::resolveStreamOpenArity($method); + } + + $this->streamOpenArity = $streamOpenArity; + } public function bidiStream( string $method, @@ -25,12 +53,22 @@ public function bidiStream( callable $onMessage, ?float $deadlineSeconds = null, ): NativeGrpcResult { + $this->assertNotCancelled(); $call = $this->invokeStreamOpen($method, $headers, $deadlineSeconds); - $this->writeMessages($call, $messages); - $this->finishClientWrites($call); - $this->drainInboundStream($call, $onMessage); - return $this->finalizeCall($call); + try { + $this->writeMessages($call, $messages); + $this->assertNotCancelled(); + $this->finishClientWrites($call); + $this->drainInboundStream($call, $onMessage); + $this->assertNotCancelled(); + + return $this->finalizeCall($call); + } catch (Throwable $exception) { + $this->cancelCall($call); + + throw $exception; + } } public function clientStream( @@ -39,11 +77,21 @@ public function clientStream( GrpcMetadata $headers, ?float $deadlineSeconds = null, ): NativeGrpcResult { + $this->assertNotCancelled(); $call = $this->invokeStreamOpen($method, $headers, $deadlineSeconds); - $this->writeMessages($call, $messages); - $this->finishClientWrites($call); - return $this->finalizeCall($call); + try { + $this->writeMessages($call, $messages); + $this->assertNotCancelled(); + $this->finishClientWrites($call); + $this->assertNotCancelled(); + + return $this->finalizeCall($call); + } catch (Throwable $exception) { + $this->cancelCall($call); + + throw $exception; + } } public function invoke( @@ -71,6 +119,7 @@ public function serverStream( callable $onMessage, ?float $deadlineSeconds = null, ): NativeGrpcResult { + $this->assertNotCancelled(); $call = $this->invokeStubMethod( $this->resolveMethodName($method), [ @@ -80,24 +129,66 @@ public function serverStream( ], ); - $this->drainInboundStream($call, $onMessage); + try { + $this->drainInboundStream($call, $onMessage); + $this->assertNotCancelled(); - return $this->finalizeCall($call); + return $this->finalizeCall($call); + } catch (Throwable $exception) { + $this->cancelCall($call); + + throw $exception; + } + } + + private static function resolveStreamOpenArity(ReflectionMethod $method): int + { + if ($method->isVariadic()) { + return $method->getNumberOfParameters() >= 3 ? 3 : 2; + } + + return match (true) { + $method->getNumberOfParameters() >= 3 => 3, + $method->getNumberOfParameters() === 2 => 2, + default => 0, + }; + } + + private function assertNotCancelled(): void + { + if ($this->cancellation?->isRequested() === true) { + throw new RuntimeException('gRPC stream operation cancelled.'); + } + } + + private function cancelCall(mixed $call): void + { + if (!is_object($call) || !method_exists($call, 'cancel')) { + return; + } + + try { + $call->cancel(); + } catch (Throwable) { + // Preserve the original stream failure/cancellation. + } } private function drainInboundStream(mixed $call, callable $onMessage): void { if (!is_object($call)) { - throw new \RuntimeException('gRPC stream call result must be an object.'); + throw new RuntimeException('gRPC stream call result must be an object.'); } if (method_exists($call, 'responses')) { + $this->assertNotCancelled(); $responses = $call->responses(); if (!is_iterable($responses)) { - throw new \RuntimeException('gRPC server stream responses() must return iterable.'); + throw new RuntimeException('gRPC server stream responses() must return iterable.'); } foreach ($responses as $response) { + $this->assertNotCancelled(); $onMessage($response); } @@ -106,18 +197,20 @@ private function drainInboundStream(mixed $call, callable $onMessage): void if (method_exists($call, 'read')) { while (true) { + $this->assertNotCancelled(); $response = $call->read(); if ($response === null) { break; } + $this->assertNotCancelled(); $onMessage($response); } return; } - throw new \RuntimeException('Unsupported gRPC stream call object: expected responses() or read().'); + throw new RuntimeException('Unsupported gRPC stream call object: expected responses() or read().'); } private function extractHeaders(mixed $call): GrpcMetadata @@ -222,7 +315,7 @@ private function extractWaitStatusMetadata(mixed $status): array private function finalizeCall(mixed $call): NativeGrpcResult { if (!is_object($call) || !method_exists($call, 'wait')) { - throw new \RuntimeException('Unsupported gRPC call object: expected wait() method.'); + throw new RuntimeException('Unsupported gRPC call object: expected wait() method.'); } $wait = $call->wait(); @@ -295,14 +388,21 @@ private function fromNativeMetadata(mixed $metadata): GrpcMetadata private function invokeStreamOpen(string $method, GrpcMetadata $headers, ?float $deadlineSeconds): mixed { $methodName = $this->resolveMethodName($method); + $arity = $this->streamOpenArity[$methodName] ?? 0; + if ($arity === 0) { + throw new RuntimeException(sprintf( + 'Unsupported generated gRPC stream signature for method "%s".', + $methodName, + )); + } + $metadata = $this->toNativeMetadata($headers); $options = $this->toNativeOptions($deadlineSeconds); + $arguments = $arity === 3 + ? [null, $metadata, $options] + : [$metadata, $options]; - try { - return $this->invokeStubMethod($methodName, [$metadata, $options]); - } catch (\ArgumentCountError|\TypeError) { - return $this->invokeStubMethod($methodName, [null, $metadata, $options]); - } + return $this->invokeStubMethod($methodName, $arguments); } /** @@ -310,9 +410,9 @@ private function invokeStreamOpen(string $method, GrpcMetadata $headers, ?float */ private function invokeStubMethod(string $methodName, array $args): mixed { - if (!method_exists($this->stubClient, $methodName)) { - throw new \RuntimeException(sprintf( - 'Generated gRPC stub method "%s" was not found on %s.', + if (!is_callable([$this->stubClient, $methodName])) { + throw new RuntimeException(sprintf( + 'Generated gRPC stub method "%s" was not found or is not public on %s.', $methodName, $this->stubClient::class, )); @@ -321,6 +421,49 @@ private function invokeStubMethod(string $methodName, array $args): mixed return $this->stubClient->{$methodName}(...$args); } + /** + * @param array $methodMap + * @return array + */ + private function normalizeMethodMap(ReflectionObject $reflection, array $methodMap): array + { + $normalized = []; + + foreach ($methodMap as $method => $stubMethod) { + if (!is_string($method) || !is_string($stubMethod) || trim($stubMethod) === '') { + throw new InvalidArgumentException('gRPC generated method maps require non-empty string keys and values.'); + } + + $normalizedMethod = GrpcMethodGuard::normalize($method); + if (isset($normalized[$normalizedMethod])) { + throw new InvalidArgumentException(sprintf( + 'Duplicate generated gRPC method mapping for "%s".', + $normalizedMethod, + )); + } + + if (!$reflection->hasMethod($stubMethod)) { + throw new InvalidArgumentException(sprintf( + 'Generated gRPC stub method "%s" was not found on %s.', + $stubMethod, + $this->stubClient::class, + )); + } + + $reflected = $reflection->getMethod($stubMethod); + if (!$reflected->isPublic() || $reflected->isStatic()) { + throw new InvalidArgumentException(sprintf( + 'Generated gRPC stub method "%s" must be a public instance method.', + $stubMethod, + )); + } + + $normalized[$normalizedMethod] = $stubMethod; + } + + return $normalized; + } + /** * @return list */ @@ -366,13 +509,13 @@ private function normalizeStatusArrayMetadata(array $status): array private function resolveMethodName(string $method): string { - $mapped = $this->methodMap[$method] ?? null; - if (is_string($mapped) && $mapped !== '') { + $normalized = GrpcMethodGuard::normalize($method); + $mapped = $this->methodMap[$normalized] ?? null; + if ($mapped !== null) { return $mapped; } - $trimmed = ltrim($method, '/'); - $parts = explode('/', $trimmed); + $parts = explode('/', ltrim($normalized, '/')); return $parts[array_key_last($parts)]; } @@ -416,10 +559,11 @@ private function toNativeOptions(?float $deadlineSeconds): array private function writeMessages(mixed $call, iterable $messages): void { if (!is_object($call) || !method_exists($call, 'write')) { - throw new \RuntimeException('Unsupported client stream call object: expected write() method.'); + throw new RuntimeException('Unsupported client stream call object: expected write() method.'); } foreach ($messages as $message) { + $this->assertNotCancelled(); $call->write($message); } } diff --git a/src/Grpc/Receiver/GrpcInboundExchange.php b/src/Grpc/Receiver/GrpcInboundExchange.php new file mode 100644 index 0000000..a393d98 --- /dev/null +++ b/src/Grpc/Receiver/GrpcInboundExchange.php @@ -0,0 +1,12 @@ +caller = Closure::fromCallable($caller); $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); } public function send(GrpcRequest $grpcRequest): CommunicationResult { - $startedAt = microtime(true); + $startedAt = $this->clock->monotonic(); $this->events->dispatch('grpc.request.start', [ 'transport' => 'grpc', 'method' => $grpcRequest->method, @@ -43,12 +48,12 @@ public function send(GrpcRequest $grpcRequest): CommunicationResult try { $response = ($this->caller)($grpcRequest); } catch (Throwable $exception) { - $durationMs = (int) ((microtime(true) - $startedAt) * 1000); + $durationMs = (int) (($this->clock->monotonic() - $startedAt) * 1000); $this->events->dispatch('grpc.request.failed', [ 'transport' => 'grpc', 'method' => $grpcRequest->method, 'duration_ms' => $durationMs, - 'error' => $exception->getMessage(), + ...ObservabilitySanitizer::throwableContext($exception), ]); $error = new GrpcCallError( @@ -75,7 +80,7 @@ public function send(GrpcRequest $grpcRequest): CommunicationResult ); } - $durationMs = (int) ((microtime(true) - $startedAt) * 1000); + $durationMs = (int) (($this->clock->monotonic() - $startedAt) * 1000); if (!$response->isOk()) { $this->events->dispatch('grpc.request.failed', [ 'transport' => 'grpc', diff --git a/src/Grpc/Testing/FakeGrpcInboundExchange.php b/src/Grpc/Testing/FakeGrpcInboundExchange.php new file mode 100644 index 0000000..7f90507 --- /dev/null +++ b/src/Grpc/Testing/FakeGrpcInboundExchange.php @@ -0,0 +1,44 @@ +completed) { + throw new LogicException('Inbound gRPC exchange has already been completed.'); + } + + $this->response = $response; + $this->completed = true; + } + + public function completed(): bool + { + return $this->completed; + } + + public function request(): GrpcInboundRequest + { + return $this->inboundRequest; + } + + public function response(): ?GrpcInboundResponse + { + return $this->response; + } +} diff --git a/src/Grpc/Testing/FakeGrpcInboundSource.php b/src/Grpc/Testing/FakeGrpcInboundSource.php new file mode 100644 index 0000000..ff96c0b --- /dev/null +++ b/src/Grpc/Testing/FakeGrpcInboundSource.php @@ -0,0 +1,54 @@ + */ + private array $accepted = []; + + /** @var list */ + private array $pending = []; + + public function accept(?CancellationSignal $cancellation = null): ?GrpcInboundExchange + { + if ($cancellation?->isRequested() === true) { + return null; + } + + $exchange = array_shift($this->pending); + if (!$exchange instanceof FakeGrpcInboundExchange) { + return null; + } + + $this->accepted[] = $exchange; + + return $exchange; + } + + /** @return list */ + public function accepted(): array + { + return $this->accepted; + } + + public function enqueue(GrpcInboundRequest $request): FakeGrpcInboundExchange + { + $exchange = new FakeGrpcInboundExchange($request); + $this->pending[] = $exchange; + + return $exchange; + } + + public function pendingCount(): int + { + return count($this->pending); + } +} diff --git a/src/Http/Concurrent/CurlMultiTransport.php b/src/Http/Concurrent/CurlMultiTransport.php index dd692b9..98f80c6 100644 --- a/src/Http/Concurrent/CurlMultiTransport.php +++ b/src/Http/Concurrent/CurlMultiTransport.php @@ -8,6 +8,9 @@ use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer; use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Http\HttpRequest; use Infocyph\TalkingBytes\Http\Internal\CurlHandleConfigurator; @@ -21,21 +24,31 @@ final readonly class CurlMultiTransport { + private Clock $clock; + private EventDispatcher $events; private Sleeper $sleeper; - public function __construct(?Sleeper $sleeper = null, ?EventDispatcher $events = null) - { + public function __construct( + ?Sleeper $sleeper = null, + ?EventDispatcher $events = null, + ?Clock $clock = null, + ) { $this->sleeper = $sleeper ?? Sleeper::system(); $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); } /** * @param array $requests */ - public function sendMany(array $requests, int $maxConcurrency = 10, bool $stopOnFailure = false): PoolResult - { + public function sendMany( + array $requests, + int $maxConcurrency = 10, + bool $stopOnFailure = false, + ?CancellationSignal $cancellation = null, + ): PoolResult { $this->events->dispatch('http.pool.start', [ 'request_count' => count($requests), 'max_concurrency' => $maxConcurrency, @@ -44,61 +57,207 @@ public function sendMany(array $requests, int $maxConcurrency = 10, bool $stopOn ]); $limit = new ConcurrencyLimit($maxConcurrency); - $chunkSize = max(1, $limit->value); + $startedAt = $this->clock->monotonic(); + $keys = array_keys($requests); + + /** @var array $results */ $results = []; - $start = microtime(true); - foreach (array_chunk($requests, $chunkSize, true) as $chunk) { - $chunkResults = $this->sendChunk($chunk); - foreach ($chunkResults as $key => $result) { - $results[$key] = $result; - } + if ($keys === []) { + return $this->finishPool($requests, $results, $startedAt, false, false); + } + + $multiHandle = curl_multi_init(); + $configurator = new CurlHandleConfigurator(); + + /** + * @var array $contexts + */ + $contexts = []; + + $nextIndex = 0; + $stoppedScheduling = false; + $cancelled = false; + + try { + while (true) { + $scheduled = [ + 'next_index' => $nextIndex, + 'stopped' => false, + 'cancelled' => false, + ]; + + if (!$stoppedScheduling) { + $scheduled = $this->scheduleAvailable( + $multiHandle, + $configurator, + $requests, + $keys, + $nextIndex, + $limit->value, + $results, + $contexts, + $stopOnFailure, + $cancellation, + ); + $nextIndex = $scheduled['next_index']; + $stoppedScheduling = $scheduled['stopped']; + } + + if ($scheduled['cancelled']) { + $cancelled = true; + $stoppedScheduling = true; + $this->cancelOutstanding($multiHandle, $keys, $nextIndex, $contexts, $results); + + break; + } - if ($stopOnFailure && $this->containsFailure($chunkResults)) { - $pool = new PoolResult( + if ($contexts === []) { + break; + } + + $execution = $this->executeMulti($multiHandle); + if ($execution['error'] !== null) { + $stoppedScheduling = true; + $this->failOutstanding( + $multiHandle, + $keys, + $nextIndex, + $contexts, + $results, + $execution['error'], + ); + + break; + } + + $completed = $this->collectCompleted( + $multiHandle, + $contexts, $results, - ['duration_ms' => (int) ((microtime(true) - $start) * 1000), 'stopped_scheduling' => true], + $stopOnFailure, ); - $this->events->dispatch('http.pool.finish', [ - 'request_count' => count($requests), - 'successful_count' => $pool->successfulCount(), - 'failed_count' => $pool->failedCount(), - 'duration_ms' => $pool->metadata['duration_ms'] ?? null, - 'stopped_scheduling' => true, - 'transport' => 'curl-multi', - ]); + $stoppedScheduling = $stoppedScheduling || $completed['failure_observed']; + + if ($cancellation?->isRequested() === true) { + $cancelled = true; + $stoppedScheduling = true; + $this->cancelOutstanding($multiHandle, $keys, $nextIndex, $contexts, $results); - return $pool; + break; + } + + if ($contexts !== [] && $completed['count'] === 0 && $execution['running'] > 0) { + $this->waitForActivity($multiHandle, $cancellation); + } } + } finally { + foreach ($contexts as $context) { + $this->abortContext($multiHandle, $context); + } + + curl_multi_close($multiHandle); } - $pool = new PoolResult( - $results, - ['duration_ms' => (int) ((microtime(true) - $start) * 1000), 'stopped_scheduling' => false], + return $this->finishPool( + $requests, + $this->orderResults($keys, $results), + $startedAt, + $stoppedScheduling, + $cancelled, ); - $this->events->dispatch('http.pool.finish', [ - 'request_count' => count($requests), - 'successful_count' => $pool->successfulCount(), - 'failed_count' => $pool->failedCount(), - 'duration_ms' => $pool->metadata['duration_ms'] ?? null, - 'stopped_scheduling' => false, - 'transport' => 'curl-multi', - ]); + } - return $pool; + private static function cancelledResult(bool $started): CommunicationResult + { + return CommunicationResult::failure( + 'HTTP concurrent operation cancelled.', + metadata: [ + 'transport' => 'curl-multi', + 'cancelled' => true, + 'started' => $started, + ], + ); } - private function cleanupUploadHandle(HttpRequest $request): void + /** + * @param array{key:int|string, handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector} $context + */ + private function abortContext(\CurlMultiHandle $multiHandle, array $context): void { - UploadHandleManager::cleanup($request); + $context['bodyCollector']->abort(); + UploadHandleManager::cleanup($context['request']); + curl_multi_remove_handle($multiHandle, $context['handle']); } /** + * @param list $keys + * @param array $contexts * @param array $results */ - private function containsFailure(array $results): bool - { - return array_any($results, static fn(CommunicationResult $result): bool => !$result->successful); + private function cancelOutstanding( + \CurlMultiHandle $multiHandle, + array $keys, + int $nextIndex, + array &$contexts, + array &$results, + ): void { + foreach ($contexts as $handleId => $context) { + $result = self::cancelledResult(started: true); + $results[$context['key']] = $result; + $this->dispatchRequestResultEvent($context['request'], $result); + $this->abortContext($multiHandle, $context); + unset($contexts[$handleId]); + } + + $total = count($keys); + for ($index = $nextIndex; $index < $total; $index++) { + $key = $keys[$index]; + if (!array_key_exists($key, $results)) { + $results[$key] = self::cancelledResult(started: false); + } + } + } + + /** + * @param array $contexts + * @param array $results + * @return array{count:int, failure_observed:bool} + */ + private function collectCompleted( + \CurlMultiHandle $multiHandle, + array &$contexts, + array &$results, + bool $stopOnFailure, + ): array { + $count = 0; + $failureObserved = false; + + while (($message = curl_multi_info_read($multiHandle)) !== false) { + $handle = $message['handle'] ?? null; + if (!$handle instanceof \CurlHandle) { + continue; + } + + $handleId = spl_object_id($handle); + $context = $contexts[$handleId] ?? null; + if ($context === null) { + continue; + } + + $result = $this->finalizeContext($context); + $results[$context['key']] = $result; + $this->dispatchRequestResultEvent($context['request'], $result); + $this->releaseContext($multiHandle, $context); + unset($contexts[$handleId]); + + $count++; + if ($stopOnFailure && !$result->successful) { + $failureObserved = true; + } + } + + return ['count' => $count, 'failure_observed' => $failureObserved]; } private function dispatchRequestResultEvent(HttpRequest $request, CommunicationResult $result): void @@ -107,13 +266,67 @@ private function dispatchRequestResultEvent(HttpRequest $request, CommunicationR 'method' => $request->method->value, 'url' => HttpRedactor::redactUrl($request->buildUrl()), 'status' => $result->statusCode, - 'error' => $result->error, + 'failure_category' => $result->successful ? null : (ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error'), 'transport' => 'curl-multi', ]); } /** - * @param array{handle: \CurlHandle, request: HttpRequest, headerCollector: ResponseHeaderCollector, bodyCollector: ResponseBodyCollector} $context + * @return array{error:?string, running:int} + */ + private function executeMulti(\CurlMultiHandle $multiHandle): array + { + $status = curl_multi_exec($multiHandle, $running); + if ($status !== CURLM_OK) { + return [ + 'error' => sprintf('cURL multi execution failed with status %d.', $status), + 'running' => 0, + ]; + } + + return ['error' => null, 'running' => is_int($running) ? $running : 0]; + } + + /** + * @param list $keys + * @param array $contexts + * @param array $results + */ + private function failOutstanding( + \CurlMultiHandle $multiHandle, + array $keys, + int $nextIndex, + array &$contexts, + array &$results, + string $error, + ): void { + foreach ($contexts as $handleId => $context) { + $result = CommunicationResult::failure($error, metadata: [ + 'transport' => 'curl-multi', + 'scheduler_error' => true, + 'started' => true, + ]); + $results[$context['key']] = $result; + $this->dispatchRequestResultEvent($context['request'], $result); + $this->abortContext($multiHandle, $context); + unset($contexts[$handleId]); + } + + $total = count($keys); + for ($index = $nextIndex; $index < $total; $index++) { + $key = $keys[$index]; + if (!array_key_exists($key, $results)) { + $results[$key] = CommunicationResult::failure($error, metadata: [ + 'transport' => 'curl-multi', + 'scheduler_error' => true, + 'started' => false, + ]); + } + } + } + + /** + * @param array{key:int|string, handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector} $context */ private function finalizeContext(array $context): CommunicationResult { @@ -122,8 +335,6 @@ private function finalizeContext(array $context): CommunicationResult $streamFinalizeError = $context['bodyCollector']->finalize(); $body = is_string($rawBody) ? $rawBody : $context['bodyCollector']->responseBody(); - $this->cleanupUploadHandle($context['request']); - if ($streamFinalizeError !== null) { return CommunicationResult::failure( $streamFinalizeError, @@ -161,8 +372,55 @@ private function finalizeContext(array $context): CommunicationResult } /** + * @param array $requests * @param array $results - * @return array{handle: \CurlHandle, request: HttpRequest, headerCollector: ResponseHeaderCollector, bodyCollector: ResponseBodyCollector}|null + */ + private function finishPool( + array $requests, + array $results, + float $startedAt, + bool $stoppedScheduling, + bool $cancelled, + ): PoolResult { + $pool = new PoolResult($results, [ + 'duration_ms' => (int) (($this->clock->monotonic() - $startedAt) * 1000), + 'stopped_scheduling' => $stoppedScheduling, + 'cancelled' => $cancelled, + ]); + + $this->events->dispatch('http.pool.finish', [ + 'request_count' => count($requests), + 'successful_count' => $pool->successfulCount(), + 'failed_count' => $pool->failedCount(), + 'duration_ms' => $pool->metadata['duration_ms'], + 'stopped_scheduling' => $stoppedScheduling, + 'cancelled' => $cancelled, + 'transport' => 'curl-multi', + ]); + + return $pool; + } + + /** + * @param list $keys + * @param array $results + * @return array + */ + private function orderResults(array $keys, array $results): array + { + $ordered = []; + foreach ($keys as $key) { + if (array_key_exists($key, $results)) { + $ordered[$key] = $results[$key]; + } + } + + return $ordered; + } + + /** + * @param array $results + * @return array{handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector}|null */ private function prepareContext( \CurlMultiHandle $multiHandle, @@ -182,14 +440,20 @@ private function prepareContext( } $pinnedResolution = RequestSecurityGuard::pinnedResolution($prepared, $prepared->buildUrl()); } catch (InvalidArgumentException $exception) { - $results[$index] = CommunicationResult::failure($exception->getMessage(), metadata: ['transport' => 'curl-multi']); + $results[$index] = CommunicationResult::failure( + $exception->getMessage(), + metadata: ['transport' => 'curl-multi'], + ); return null; } $handle = curl_init(); if ($handle === false) { - $results[$index] = CommunicationResult::failure('Unable to initialize cURL handle.'); + $results[$index] = CommunicationResult::failure( + 'Unable to initialize cURL handle.', + metadata: ['transport' => 'curl-multi'], + ); return null; } @@ -201,9 +465,13 @@ private function prepareContext( $bodyCollector = new ResponseBodyCollector($prepared, $collector); $prepared = $configurator->configure($handle, $prepared, $collector, $bodyCollector, $pinnedResolution); } catch (InvalidArgumentException $exception) { - $bodyCollector?->finalize(); + $bodyCollector?->abort(); + UploadHandleManager::cleanup($prepared); unset($handle); - $results[$index] = CommunicationResult::failure($exception->getMessage()); + $results[$index] = CommunicationResult::failure( + $exception->getMessage(), + metadata: ['transport' => 'curl-multi'], + ); return null; } @@ -217,11 +485,12 @@ private function prepareContext( $status = curl_multi_add_handle($multiHandle, $handle); if ($status !== CURLM_OK) { - $bodyCollector->finalize(); + $bodyCollector->abort(); UploadHandleManager::cleanup($prepared); unset($handle); $results[$index] = CommunicationResult::failure( sprintf('Unable to add request to cURL multi handle (%d).', $status), + metadata: ['transport' => 'curl-multi'], ); return null; @@ -235,74 +504,83 @@ private function prepareContext( ]; } - private function runMultiLoop(\CurlMultiHandle $multiHandle): ?string + /** + * @param array{key:int|string, handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector} $context + */ + private function releaseContext(\CurlMultiHandle $multiHandle, array $context): void { - do { - $status = curl_multi_exec($multiHandle, $running); - if ($status !== CURLM_OK) { - return sprintf('cURL multi execution failed with status %d.', $status); - } - - if ($running > 0 && curl_multi_select($multiHandle, 1.0) === -1) { - $this->sleeper->milliseconds(10); - } - } while ($running > 0); - - return null; + UploadHandleManager::cleanup($context['request']); + curl_multi_remove_handle($multiHandle, $context['handle']); } /** * @param array $requests - * @return array + * @param list $keys + * @param array $results + * @param array $contexts + * @return array{next_index:int, stopped:bool, cancelled:bool} */ - private function sendChunk(array $requests): array - { - $multiHandle = curl_multi_init(); - - /** - * @var array $contexts - */ - $contexts = []; - - /** @var array $results */ - $results = []; + private function scheduleAvailable( + \CurlMultiHandle $multiHandle, + CurlHandleConfigurator $configurator, + array $requests, + array $keys, + int $nextIndex, + int $maxConcurrency, + array &$results, + array &$contexts, + bool $stopOnFailure, + ?CancellationSignal $cancellation, + ): array { + $total = count($keys); + + while ($nextIndex < $total && count($contexts) < $maxConcurrency) { + if ($cancellation?->isRequested() === true) { + return ['next_index' => $nextIndex, 'stopped' => true, 'cancelled' => true]; + } - $configurator = new CurlHandleConfigurator(); + $key = $keys[$nextIndex]; + $nextIndex++; + $context = $this->prepareContext( + $multiHandle, + $configurator, + $requests[$key], + $results, + $key, + ); - try { - foreach ($requests as $index => $request) { - $context = $this->prepareContext($multiHandle, $configurator, $request, $results, $index); - if ($context === null) { - continue; + if ($context === null) { + if ($stopOnFailure && isset($results[$key]) && !$results[$key]->successful) { + return ['next_index' => $nextIndex, 'stopped' => true, 'cancelled' => false]; } - $contexts[$index] = $context; + continue; } - $multiError = $this->runMultiLoop($multiHandle); + $contexts[spl_object_id($context['handle'])] = [ + 'key' => $key, + ...$context, + ]; + } - foreach ($contexts as $index => $context) { - $results[$index] = $multiError === null - ? $this->finalizeContext($context) - : CommunicationResult::failure($multiError, metadata: ['transport' => 'curl-multi']); - $this->dispatchRequestResultEvent($context['request'], $results[$index]); - } - } finally { - foreach ($contexts as $context) { - $context['bodyCollector']->finalize(); - UploadHandleManager::cleanup($context['request']); - curl_multi_remove_handle($multiHandle, $context['handle']); - } - curl_multi_close($multiHandle); + return ['next_index' => $nextIndex, 'stopped' => false, 'cancelled' => false]; + } + + private function waitForActivity( + \CurlMultiHandle $multiHandle, + ?CancellationSignal $cancellation, + ): void { + $timeoutSeconds = $cancellation === null ? 1.0 : 0.05; + if (curl_multi_select($multiHandle, $timeoutSeconds) !== -1) { + return; } - $orderedResults = []; - foreach (array_keys($requests) as $key) { - if (array_key_exists($key, $results)) { - $orderedResults[$key] = $results[$key]; - } + if ($cancellation === null) { + $this->sleeper->milliseconds(10); + + return; } - return $orderedResults; + $this->sleeper->millisecondsInterruptibly(10, $cancellation, 10); } } diff --git a/src/Http/Concurrent/RequestPool.php b/src/Http/Concurrent/RequestPool.php index 86d2847..c6a2163 100644 --- a/src/Http/Concurrent/RequestPool.php +++ b/src/Http/Concurrent/RequestPool.php @@ -4,6 +4,7 @@ namespace Infocyph\TalkingBytes\Http\Concurrent; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; use Infocyph\TalkingBytes\Http\HttpRequest; final readonly class RequestPool @@ -12,11 +13,17 @@ public function __construct( private CurlMultiTransport $transport, private int $maxConcurrency = 10, private bool $stopOnFailure = false, + private ?CancellationSignal $cancellation = null, ) {} public function maxConcurrency(int $maxConcurrency): self { - return new self($this->transport, $maxConcurrency, $this->stopOnFailure); + return new self( + $this->transport, + $maxConcurrency, + $this->stopOnFailure, + $this->cancellation, + ); } /** @@ -24,11 +31,31 @@ public function maxConcurrency(int $maxConcurrency): self */ public function sendMany(array $requests): PoolResult { - return $this->transport->sendMany($requests, $this->maxConcurrency, $this->stopOnFailure); + return $this->transport->sendMany( + $requests, + $this->maxConcurrency, + $this->stopOnFailure, + $this->cancellation, + ); } public function stopSchedulingOnFailure(bool $enabled = true): self { - return new self($this->transport, $this->maxConcurrency, $enabled); + return new self( + $this->transport, + $this->maxConcurrency, + $enabled, + $this->cancellation, + ); + } + + public function withCancellation(?CancellationSignal $cancellation): self + { + return new self( + $this->transport, + $this->maxConcurrency, + $this->stopOnFailure, + $cancellation, + ); } } diff --git a/src/Http/HttpClient.php b/src/Http/HttpClient.php index f99a865..8f1092e 100644 --- a/src/Http/HttpClient.php +++ b/src/Http/HttpClient.php @@ -12,6 +12,8 @@ use Infocyph\TalkingBytes\Auth\SignedRequestAuth; use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; use Infocyph\TalkingBytes\Http\Body\MultipartBody; use Infocyph\TalkingBytes\Http\Contract\HttpMiddleware; use Infocyph\TalkingBytes\Http\Contract\HttpTransport; @@ -53,9 +55,9 @@ private function __construct( $this->pipeline = new HttpPipeline($transport, $middlewares); } - public static function curl(?EventDispatcher $events = null): self + public static function curl(?EventDispatcher $events = null, ?Clock $clock = null): self { - return new self(new CurlTransport($events)); + return new self(new CurlTransport($events, $clock)); } public static function fake(?FakeHttpTransport $transport = null): self @@ -63,10 +65,14 @@ public static function fake(?FakeHttpTransport $transport = null): self return new self($transport ?? new FakeHttpTransport()); } - public static function fromConfig(HttpClientConfig $config): self - { + public static function fromConfig( + HttpClientConfig $config, + ?EventDispatcher $events = null, + ?HttpTransport $transport = null, + ?Clock $clock = null, + ): self { return new self( - transport: new CurlTransport(), + transport: $transport ?? new CurlTransport($events, $clock), defaultOptions: new CurlOptions( timeoutSeconds: $config->timeoutSeconds, connectTimeoutSeconds: $config->connectTimeoutSeconds, @@ -86,9 +92,29 @@ public static function fromConfig(HttpClientConfig $config): self ); } - public static function multi(int $maxConcurrency = 10, ?EventDispatcher $events = null): Concurrent\RequestPool - { - return new Concurrent\RequestPool(new Concurrent\CurlMultiTransport(events: $events), $maxConcurrency); + /** + * @param array $config + */ + public static function fromResolvedConfig( + array $config, + ?EventDispatcher $events = null, + ?CancellationSignal $cancellation = null, + ?HttpTransport $transport = null, + ?Clock $clock = null, + ): self { + return new HttpClientFactory($events, $cancellation, $clock)->fromArray($config, $transport); + } + + public static function multi( + int $maxConcurrency = 10, + ?EventDispatcher $events = null, + ?CancellationSignal $cancellation = null, + ): Concurrent\RequestPool { + return new Concurrent\RequestPool( + new Concurrent\CurlMultiTransport(events: $events), + $maxConcurrency, + cancellation: $cancellation, + ); } public static function multipart(): MultipartBody @@ -296,9 +322,11 @@ public function withHeaders(array $headers): self return new self($this->transport, $this->middlewares, $this->defaultOptions, $headers, $this->authenticators, $this->cookieJar); } - public function withHttpRetry(?HttpRetryPolicy $policy = null): self - { - return $this->withRetry($policy ?? HttpRetryPolicy::standard()); + public function withHttpRetry( + ?HttpRetryPolicy $policy = null, + ?CancellationSignal $cancellation = null, + ): self { + return $this->withRetry($policy ?? HttpRetryPolicy::standard(), $cancellation); } public function withIdempotency(string $headerName = 'Idempotency-Key'): self @@ -332,9 +360,9 @@ public function withRateLimit(RateLimiter $rateLimiter): self return $this->withMiddleware(new RateLimitMiddleware($rateLimiter)); } - public function withRetry(RetryPolicy $policy): self + public function withRetry(RetryPolicy $policy, ?CancellationSignal $cancellation = null): self { - return $this->withMiddleware(new RetryMiddleware($policy)); + return $this->withMiddleware(new RetryMiddleware($policy, $cancellation)); } public function withSigner(RequestSigner $signer): self diff --git a/src/Http/HttpClientFactory.php b/src/Http/HttpClientFactory.php new file mode 100644 index 0000000..db79866 --- /dev/null +++ b/src/Http/HttpClientFactory.php @@ -0,0 +1,197 @@ + $config + */ + public function fromArray(array $config, ?HttpTransport $transport = null): HttpClient + { + $client = HttpClient::fromConfig( + HttpClientConfig::fromArray($config), + $this->events, + $transport, + $this->clock, + ); + + $client = $this->applyAuth($client, self::section($config, 'auth')); + + if (self::enabled(self::section($config, 'cookies'))) { + $client = $client->withCookieJar(new CookieJar()); + } + + $retry = self::section($config, 'retry'); + if (self::enabled($retry)) { + $client = $client->withHttpRetry( + HttpRetryPolicy::standard( + self::int($retry, 'attempts', 3), + self::int($retry, 'base_delay_ms', 250), + self::int($retry, 'max_retry_after_seconds', 30), + ), + $this->cancellation, + ); + } + + $rateLimit = self::section($config, 'rate_limit'); + if (self::enabled($rateLimit)) { + $client = $client->withRateLimit(new RateLimiter( + self::int($rateLimit, 'max_requests', 60), + self::int($rateLimit, 'per_seconds', 60), + )); + } + + $circuit = self::section($config, 'circuit_breaker'); + if (self::enabled($circuit)) { + $client = $client->withCircuitBreaker(new CircuitBreaker( + self::int($circuit, 'failure_threshold', 5), + self::int($circuit, 'cool_down_seconds', 30), + )); + } + + $idempotency = self::section($config, 'idempotency'); + if (self::enabled($idempotency)) { + $client = $client->withIdempotency( + self::string($idempotency, 'header', 'Idempotency-Key', true), + ); + } + + return $client; + } + + /** @param array $config */ + private static function bool(array $config, string $key, bool $default): bool + { + if (!array_key_exists($key, $config)) { + return $default; + } + + $value = $config[$key]; + if (is_bool($value)) { + return $value; + } + + if (is_int($value) && ($value === 0 || $value === 1)) { + return $value === 1; + } + + if (is_string($value)) { + $parsed = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + if (is_bool($parsed)) { + return $parsed; + } + } + + throw new InvalidArgumentException(sprintf('HTTP resolved configuration key "%s" must be a boolean.', $key)); + } + + /** @param array $config */ + private static function enabled(array $config): bool + { + return self::bool($config, 'enabled', false); + } + + /** @param array $config */ + private static function int(array $config, string $key, int $default): int + { + $value = $config[$key] ?? $default; + if (is_int($value)) { + return $value; + } + + if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) { + $parsed = filter_var($value, FILTER_VALIDATE_INT); + if (is_int($parsed)) { + return $parsed; + } + } + + throw new InvalidArgumentException(sprintf('HTTP resolved configuration key "%s" must be an integer.', $key)); + } + + /** + * @param array $config + * @return array + */ + private static function section(array $config, string $key): array + { + $value = $config[$key] ?? []; + if (!is_array($value)) { + throw new InvalidArgumentException(sprintf('HTTP resolved configuration section "%s" must be an array.', $key)); + } + + $section = []; + foreach ($value as $name => $item) { + if (is_string($name)) { + $section[$name] = $item; + } + } + + return $section; + } + + /** @param array $config */ + private static function string( + array $config, + string $key, + string $default = '', + bool $required = false, + ): string { + $value = $config[$key] ?? $default; + if (!is_string($value)) { + throw new InvalidArgumentException(sprintf('HTTP resolved configuration key "%s" must be a string.', $key)); + } + + $value = trim($value); + if ($required && $value === '') { + throw new InvalidArgumentException(sprintf('HTTP resolved configuration key "%s" must be non-empty.', $key)); + } + + return $value; + } + + /** + * @param array $auth + */ + private function applyAuth(HttpClient $client, array $auth): HttpClient + { + return match (self::string($auth, 'driver', 'none')) { + 'api_key', 'api_key_header', 'api-key-header', 'header' => $client->withApiKeyHeader( + self::string($auth, 'header', 'X-Api-Key', true), + self::string($auth, 'value', '', true), + ), + 'api_key_query', 'api-key-query', 'query' => $client->withApiKeyQuery( + self::string($auth, 'query_key', 'api_key', true), + self::string($auth, 'value', '', true), + ), + 'basic' => $client->withBasicAuth( + self::string($auth, 'username', '', true), + self::string($auth, 'password', '', true), + ), + 'bearer' => $client->withBearerToken(self::string($auth, 'token', '', true)), + 'none', '' => $client, + default => throw new InvalidArgumentException('Unsupported HTTP auth driver.'), + }; + } +} diff --git a/src/Http/Internal/ResponseBodyCollector.php b/src/Http/Internal/ResponseBodyCollector.php index f39d464..42faf3f 100644 --- a/src/Http/Internal/ResponseBodyCollector.php +++ b/src/Http/Internal/ResponseBodyCollector.php @@ -33,6 +33,21 @@ public function __construct( $this->tempPath = null; } + public function abort(): void + { + if ($this->finalized) { + return; + } + + $this->finalized = true; + if (is_resource($this->stream)) { + fclose($this->stream); + } + + $this->stream = null; + $this->cleanupTempFile(); + } + public function collect(string $chunk): int { if ($this->finalized) { diff --git a/src/Http/Middleware/RetryMiddleware.php b/src/Http/Middleware/RetryMiddleware.php index 8da00c1..6d8e18f 100644 --- a/src/Http/Middleware/RetryMiddleware.php +++ b/src/Http/Middleware/RetryMiddleware.php @@ -6,6 +6,7 @@ use Closure; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; use Infocyph\TalkingBytes\Core\Support\RetryExecutor; use Infocyph\TalkingBytes\Http\Contract\HttpMiddleware; use Infocyph\TalkingBytes\Http\Enum\HttpMethod; @@ -15,7 +16,10 @@ final readonly class RetryMiddleware implements HttpMiddleware { - public function __construct(private RetryPolicy $policy) {} + public function __construct( + private RetryPolicy $policy, + private ?CancellationSignal $cancellation = null, + ) {} public function handle(HttpRequest $request, Closure $next): CommunicationResult { @@ -27,7 +31,11 @@ public function handle(HttpRequest $request, Closure $next): CommunicationResult throw new InvalidArgumentException('Automatic retry requires a repeatable HTTP upload source.'); } - return RetryExecutor::run($this->policy, static fn(): CommunicationResult => $next($request)); + return RetryExecutor::run( + $this->policy, + static fn(): CommunicationResult => $next($request), + cancellation: $this->cancellation, + ); } private function isRetrySafe(HttpRequest $request): bool diff --git a/src/Http/Support/HttpRedactor.php b/src/Http/Support/HttpRedactor.php index 6e9710c..10d2444 100644 --- a/src/Http/Support/HttpRedactor.php +++ b/src/Http/Support/HttpRedactor.php @@ -11,12 +11,14 @@ final class HttpRedactor */ private const array SENSITIVE_HEADERS = [ 'authorization', + 'proxy-authorization', 'cookie', 'set-cookie', 'x-api-key', 'api-key', 'x-auth-token', 'x-access-token', + 'x-tb-signature', ]; /** diff --git a/src/Http/Transport/CurlTransport.php b/src/Http/Transport/CurlTransport.php index 6d33571..3ccaccd 100644 --- a/src/Http/Transport/CurlTransport.php +++ b/src/Http/Transport/CurlTransport.php @@ -8,6 +8,8 @@ use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer; use Infocyph\TalkingBytes\Http\Contract\HttpTransport; use Infocyph\TalkingBytes\Http\HttpRequest; use Infocyph\TalkingBytes\Http\Internal\CurlHandleConfigurator; @@ -23,17 +25,20 @@ final readonly class CurlTransport implements HttpTransport { + private Clock $clock; + private EventDispatcher $events; - public function __construct(?EventDispatcher $events = null) + public function __construct(?EventDispatcher $events = null, ?Clock $clock = null) { $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher()); + $this->clock = $clock ?? Clock::system(); } public function send(HttpRequest $request): CommunicationResult { $request = $request->prepareForTransport(); - $startedAt = microtime(true); + $startedAt = $this->clock->monotonic(); $this->dispatchStartEvent($request, $request->buildUrl()); $current = $request; $visited = []; @@ -195,7 +200,7 @@ private function dispatchResultEvents(HttpRequest $request, CommunicationResult 'url' => HttpRedactor::redactUrl($request->buildUrl()), 'status' => $result->statusCode, 'successful' => $result->successful, - 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000), + 'duration_ms' => (int) (($this->clock->monotonic() - $startedAt) * 1000), 'transport' => 'curl', ]; @@ -207,7 +212,7 @@ private function dispatchResultEvents(HttpRequest $request, CommunicationResult $this->events->dispatch('http.request.failed', [ ...$payload, - 'error' => $result->error, + 'failure_category' => ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error', ]); } diff --git a/src/Webhook/Contracts/WebhookReplayStore.php b/src/Webhook/Contracts/WebhookReplayStore.php index b10e085..065e76a 100644 --- a/src/Webhook/Contracts/WebhookReplayStore.php +++ b/src/Webhook/Contracts/WebhookReplayStore.php @@ -9,7 +9,9 @@ interface WebhookReplayStore /** * Atomically claim a delivery identity for the TTL. * - * Returns true only for the first claimant. + * Production implementations must provide one-winner semantics across + * competing processes. Returns true only for the first claimant. + * Backend failures must throw; callers treat them as fail-closed. */ public function claim(string $namespace, string $deliveryId, int $ttlSeconds): bool; } diff --git a/src/Webhook/Model/WebhookSignature.php b/src/Webhook/Model/WebhookSignature.php index f176a1a..097bfff 100644 --- a/src/Webhook/Model/WebhookSignature.php +++ b/src/Webhook/Model/WebhookSignature.php @@ -5,6 +5,7 @@ namespace Infocyph\TalkingBytes\Webhook\Model; use Infocyph\TalkingBytes\Webhook\Signing\HmacWebhookSigner; +use Infocyph\TalkingBytes\Webhook\Support\WebhookNameGuard; use InvalidArgumentException; final readonly class WebhookSignature @@ -16,10 +17,23 @@ public function __construct(#[\SensitiveParameter] private string $secret) } } - public function buildHeader(string $payload, int $timestamp): string + public static function deliveryPayload(string $payload, string $event, string $deliveryId): string { + WebhookNameGuard::assertEvent($event); + WebhookNameGuard::assertDeliveryId($deliveryId); + + return "talkingbytes.webhook.v2\0" . $event . "\0" . $deliveryId . "\0" . $payload; + } + + public function buildHeader(string $payload, int $timestamp, ?string $event = null, ?string $deliveryId = null): string + { + $version = 'v1'; + if ($event !== null || $deliveryId !== null) { + $payload = self::deliveryPayload($payload, $event ?? '', $deliveryId ?? ''); + $version = 'v2'; + } $signature = new HmacWebhookSigner()->sign($payload, $timestamp, $this->secret); - return sprintf('t=%d,v1=%s', $timestamp, $signature); + return sprintf('t=%d,%s=%s', $timestamp, $version, $signature); } } diff --git a/src/Webhook/Replay/InMemoryWebhookReplayStore.php b/src/Webhook/Replay/InMemoryWebhookReplayStore.php index baf246e..df349b8 100644 --- a/src/Webhook/Replay/InMemoryWebhookReplayStore.php +++ b/src/Webhook/Replay/InMemoryWebhookReplayStore.php @@ -10,6 +10,11 @@ use InvalidArgumentException; use RuntimeException; +/** + * Single-process replay store for tests and local development. + * + * It does not provide cross-process contention guarantees. + */ final class InMemoryWebhookReplayStore implements WebhookReplayStore { private readonly Clock $clock; diff --git a/src/Webhook/Signing/WebhookSignatureParser.php b/src/Webhook/Signing/WebhookSignatureParser.php index 24d1d10..d850a06 100644 --- a/src/Webhook/Signing/WebhookSignatureParser.php +++ b/src/Webhook/Signing/WebhookSignatureParser.php @@ -15,7 +15,7 @@ final class WebhookSignatureParser /** * @return array{timestamp:int,signatures:list}|null */ - public function parse(string $signatureHeader, ?string $timestampHeader = null): ?array + public function parse(string $signatureHeader, ?string $timestampHeader = null, string $version = 'v1'): ?array { if (strlen($signatureHeader) > self::MAX_HEADER_BYTES) { return null; @@ -33,7 +33,7 @@ public function parse(string $signatureHeader, ?string $timestampHeader = null): $signatures = []; foreach ($segments as $segment) { - [$timestamp, $candidate] = $this->parseSegment($segment, $timestamp); + [$timestamp, $candidate] = $this->parseSegment($segment, $timestamp, $version); if ($candidate !== null) { $signatures[] = $candidate; if (count($signatures) > self::MAX_SIGNATURES) { @@ -66,7 +66,7 @@ private function parseOptionalTimestamp(?string $timestampHeader): ?int /** * @return array{0:?int,1:?string} */ - private function parseSegment(string $segment, ?int $timestamp): array + private function parseSegment(string $segment, ?int $timestamp, string $version): array { $parts = explode('=', trim($segment), 2); if (count($parts) !== 2) { @@ -82,7 +82,7 @@ private function parseSegment(string $segment, ?int $timestamp): array return [$timestamp, null]; } - if ($parts[0] !== 'v1') { + if ($parts[0] !== $version) { return [$timestamp, null]; } diff --git a/src/Webhook/Testing/WebhookTestFactory.php b/src/Webhook/Testing/WebhookTestFactory.php index 700e0f6..045a3b4 100644 --- a/src/Webhook/Testing/WebhookTestFactory.php +++ b/src/Webhook/Testing/WebhookTestFactory.php @@ -26,7 +26,7 @@ public static function signedJson( $issuedAt = $timestamp ?? time(); $delivery = $deliveryId ?? bin2hex(random_bytes(16)); WebhookNameGuard::assertDeliveryId($delivery); - $signature = new WebhookSignature($secret)->buildHeader($rawPayload, $issuedAt); + $signature = new WebhookSignature($secret)->buildHeader($rawPayload, $issuedAt, $event, $delivery); return [ $rawPayload, diff --git a/src/Webhook/Webhook.php b/src/Webhook/Webhook.php index 9524eb2..1ab5ee2 100644 --- a/src/Webhook/Webhook.php +++ b/src/Webhook/Webhook.php @@ -5,8 +5,12 @@ namespace Infocyph\TalkingBytes\Webhook; use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Http\HttpClient; +use Infocyph\TalkingBytes\Webhook\Contracts\WebhookReplayStore; use Infocyph\TalkingBytes\Webhook\Testing\FakeWebhookSender; +use InvalidArgumentException; final readonly class Webhook { @@ -21,14 +25,184 @@ public static function receiver(#[\SensitiveParameter] string|array $secret, int return new WebhookReceiver(new WebhookVerifier($secret, $maxAgeSeconds, events: $events), events: $events); } + /** + * @param string|list $secret + * @param array $config + */ + public static function receiverFromResolvedConfig( + #[\SensitiveParameter] + string|array $secret, + array $config, + ?WebhookReplayStore $replayStore = null, + ?EventDispatcher $events = null, + ): WebhookReceiver { + $receiver = new WebhookReceiver( + self::verifierFromResolvedConfig($secret, $config, $events), + maxPayloadBytes: self::int($config, 'max_payload_bytes', 1_048_576), + events: $events, + ); + + $replay = self::section($config, 'replay'); + $enabled = self::bool($replay, 'enabled', $replayStore !== null); + if (!$enabled) { + return $receiver; + } + + if (!$replayStore instanceof WebhookReplayStore) { + throw new InvalidArgumentException('Resolved webhook replay configuration requires a replay store.'); + } + + return $receiver->withReplayStore( + $replayStore, + self::int($replay, 'ttl_seconds', 86_400), + self::string($replay, 'namespace', 'default'), + ); + } + public static function sender(HttpClient $httpClient, ?EventDispatcher $events = null): WebhookSender { return new WebhookSender($httpClient, events: $events); } + /** + * @param array $config + */ + public static function senderFromResolvedConfig( + HttpClient $httpClient, + array $config, + ?EventDispatcher $events = null, + ?Clock $clock = null, + ?Sleeper $sleeper = null, + ): WebhookSender { + $sender = new WebhookSender( + $httpClient, + maxPayloadBytes: self::int($config, 'max_payload_bytes', 1_048_576), + events: $events, + clock: $clock, + sleeper: $sleeper, + ); + + $secret = $config['signing_secret'] ?? null; + if ($secret !== null) { + if (!is_string($secret) || trim($secret) === '') { + throw new InvalidArgumentException('Resolved webhook signing_secret must be a non-empty string.'); + } + $sender = $sender->withSecret($secret); + } + + $retry = self::section($config, 'retry'); + if (self::bool($retry, 'enabled', false)) { + $sender = $sender->withRetryProfile( + self::int($retry, 'attempts', 3), + self::int($retry, 'base_delay_ms', 250), + self::int($retry, 'max_retry_after_seconds', 30), + ); + } + + return $sender; + } + /** @param string|list $secret */ public static function verifier(#[\SensitiveParameter] string|array $secret, int $maxAgeSeconds = 300, ?EventDispatcher $events = null): WebhookVerifier { return new WebhookVerifier($secret, $maxAgeSeconds, events: $events); } + + /** + * @param string|list $secret + * @param array $config + */ + public static function verifierFromResolvedConfig( + #[\SensitiveParameter] + string|array $secret, + array $config, + ?EventDispatcher $events = null, + ): WebhookVerifier { + return self::verifier( + $secret, + self::int($config, 'max_age_seconds', 300), + $events, + ); + } + + /** @param array $config */ + private static function bool(array $config, string $key, bool $default): bool + { + if (!array_key_exists($key, $config)) { + return $default; + } + + $value = $config[$key]; + if (is_bool($value)) { + return $value; + } + + if (is_int($value) && ($value === 0 || $value === 1)) { + return $value === 1; + } + + if (is_string($value)) { + $parsed = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + if (is_bool($parsed)) { + return $parsed; + } + } + + throw new InvalidArgumentException(sprintf('Webhook resolved configuration key "%s" must be a boolean.', $key)); + } + + /** @param array $config */ + private static function int(array $config, string $key, int $default): int + { + $value = $config[$key] ?? $default; + if (is_int($value)) { + return $value; + } + + if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) { + $parsed = filter_var($value, FILTER_VALIDATE_INT); + if (is_int($parsed)) { + return $parsed; + } + } + + throw new InvalidArgumentException(sprintf('Webhook resolved configuration key "%s" must be an integer.', $key)); + } + + /** + * @param array $config + * @return array + */ + private static function section(array $config, string $key): array + { + $value = $config[$key] ?? []; + if (!is_array($value)) { + throw new InvalidArgumentException(sprintf('Webhook resolved configuration section "%s" must be an array.', $key)); + } + + $section = []; + foreach ($value as $name => $item) { + if (is_string($name)) { + $section[$name] = $item; + } + } + + return $section; + } + + /** @param array $config */ + private static function string(array $config, string $key, string $default): string + { + $value = $config[$key] ?? $default; + if (!is_string($value)) { + throw new InvalidArgumentException(sprintf('Webhook resolved configuration key "%s" must be a string.', $key)); + } + + $value = trim($value); + if ($value === '') { + throw new InvalidArgumentException(sprintf('Webhook resolved configuration key "%s" must be non-empty.', $key)); + } + + return $value; + } } diff --git a/src/Webhook/WebhookReceiver.php b/src/Webhook/WebhookReceiver.php index 83bc986..a19d6c6 100644 --- a/src/Webhook/WebhookReceiver.php +++ b/src/Webhook/WebhookReceiver.php @@ -43,10 +43,23 @@ public function receive(string $rawBody, array $headers): WebhookEvent throw new InvalidArgumentException(sprintf('Webhook payload exceeded %d bytes.', $this->maxPayloadBytes)); } + $event = $this->header($headers, WebhookHeaders::EVENT); + $deliveryId = $this->header($headers, WebhookHeaders::DELIVERY); + if ($event === null) { + throw new InvalidArgumentException('Webhook event header is missing.'); + } + + if ($deliveryId === null) { + throw new InvalidArgumentException('Webhook delivery header is missing.'); + } + + WebhookNameGuard::assertEvent($event); + WebhookNameGuard::assertDeliveryId($deliveryId); + $signatureHeader = $this->header($headers, WebhookHeaders::SIGNATURE) ?? ''; $timestampHeader = $this->header($headers, WebhookHeaders::TIMESTAMP); - $verification = $this->verifier->verifyResult($rawBody, $signatureHeader, $timestampHeader); + $verification = $this->verifier->verifyResult($rawBody, $signatureHeader, $timestampHeader, event: $event, deliveryId: $deliveryId); if (!$verification->valid) { throw new RuntimeException(sprintf('Webhook verification failed: %s', (string) $verification->reason)); } @@ -61,19 +74,6 @@ public function receive(string $rawBody, array $headers): WebhookEvent throw new InvalidArgumentException('Webhook payload must decode to an object/array JSON value.'); } - $event = $this->header($headers, WebhookHeaders::EVENT); - $deliveryId = $this->header($headers, WebhookHeaders::DELIVERY); - if ($event === null) { - throw new InvalidArgumentException('Webhook event header is missing.'); - } - - if ($deliveryId === null) { - throw new InvalidArgumentException('Webhook delivery header is missing.'); - } - - WebhookNameGuard::assertEvent($event); - WebhookNameGuard::assertDeliveryId($deliveryId); - if ($this->replayStore !== null) { if (!$this->replayStore->claim($this->replayNamespace, $deliveryId, $this->replayTtlSeconds)) { throw new RuntimeException(sprintf('Webhook delivery "%s" has already been processed.', $deliveryId)); diff --git a/src/Webhook/WebhookSender.php b/src/Webhook/WebhookSender.php index 76afcbe..b3954b8 100644 --- a/src/Webhook/WebhookSender.php +++ b/src/Webhook/WebhookSender.php @@ -7,7 +7,10 @@ use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher; use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; +use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer; use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Http\HttpClient; use Infocyph\TalkingBytes\Http\HttpRequest; @@ -15,6 +18,7 @@ use Infocyph\TalkingBytes\Retry\RetryContext; use Infocyph\TalkingBytes\Webhook\Model\WebhookDelivery; use Infocyph\TalkingBytes\Webhook\Model\WebhookDeliveryResult; +use Infocyph\TalkingBytes\Webhook\Model\WebhookSignature; use Infocyph\TalkingBytes\Webhook\Retry\WebhookRetryProfile; use Infocyph\TalkingBytes\Webhook\Signing\HmacWebhookSigner; use Infocyph\TalkingBytes\Webhook\Signing\WebhookSigner; @@ -42,6 +46,7 @@ public function __construct( ?EventDispatcher $events = null, ?Clock $clock = null, ?Sleeper $sleeper = null, + private ?CancellationSignal $cancellation = null, ) { if ($this->maxPayloadBytes < 1) { throw new InvalidArgumentException('Webhook max payload bytes must be greater than zero.'); @@ -69,10 +74,20 @@ public static function usingHttpWithRetryProfile( ?EventDispatcher $events = null, ?Clock $clock = null, ?Sleeper $sleeper = null, + ?CancellationSignal $cancellation = null, ): self { $profile = WebhookRetryProfile::standard($attempts, $baseDelayMs, $maxRetryAfterSeconds); - return new self($httpClient, null, null, $profile, events: $events, clock: $clock, sleeper: $sleeper); + return new self( + $httpClient, + null, + null, + $profile, + events: $events, + clock: $clock, + sleeper: $sleeper, + cancellation: $cancellation, + ); } public function send(WebhookMessage $webhook): WebhookDelivery @@ -87,11 +102,21 @@ public function send(WebhookMessage $webhook): WebhookDelivery 'url' => $redactedUrl, 'attempt' => 1, ]); - $startedAt = microtime(true); + $startedAt = $this->clock->monotonic(); $attempt = 1; + $completedAttempts = 0; + $cancelled = false; $retryPolicy = $this->retryProfile?->toHttpRetryPolicy(); + $result = null; while (true) { + if ($this->cancellation?->isRequested() === true) { + $cancelled = true; + $result = $this->cancelledResult($result, $completedAttempts); + + break; + } + $timestamp = (int) floor($this->clock->timestamp()); $request = HttpRequest::post($url) ->raw($payload, 'application/json') @@ -103,11 +128,12 @@ public function send(WebhookMessage $webhook): WebhookDelivery ->header(WebhookHeaders::CONTENT_TYPE, 'application/json'); if ($this->signingSecret !== null) { - $signature = $this->signature($payload, $timestamp); - $request = $request->header(WebhookHeaders::SIGNATURE, sprintf('t=%d,v1=%s', $timestamp, $signature)); + $signature = $this->signature(WebhookSignature::deliveryPayload($payload, $webhook->event, $webhook->deliveryId), $timestamp); + $request = $request->header(WebhookHeaders::SIGNATURE, sprintf('t=%d,v2=%s', $timestamp, $signature)); } $result = $this->httpClient->send($request); + $completedAttempts = $attempt; $decision = $retryPolicy?->decide(new RetryContext($attempt, $result)); if ($decision === null || !$decision->retry) { @@ -120,12 +146,19 @@ public function send(WebhookMessage $webhook): WebhookDelivery 'url' => $redactedUrl, 'attempt' => $attempt + 1, 'status_code' => $result->statusCode, - 'error' => $result->error, + 'failure_category' => ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error', ]); $delayMs = $decision->delayMs; if ($delayMs > 0) { - $this->sleeper->milliseconds($delayMs); + if ($this->cancellation === null) { + $this->sleeper->milliseconds($delayMs); + } elseif (!$this->sleeper->millisecondsInterruptibly($delayMs, $this->cancellation)) { + $cancelled = true; + $result = $this->cancelledResult($result, $completedAttempts); + + break; + } } $attempt++; @@ -134,13 +167,14 @@ public function send(WebhookMessage $webhook): WebhookDelivery deliveryId: $webhook->deliveryId, event: $webhook->event, url: $url, - attempts: $attempt, + attempts: $completedAttempts, delivered: $result->successful, statusCode: $result->statusCode, error: $result->error, metadata: [ - 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000), + 'duration_ms' => (int) (($this->clock->monotonic() - $startedAt) * 1000), 'has_signature' => $this->signingSecret !== null, + 'cancelled' => $cancelled, ], ); @@ -151,9 +185,12 @@ public function send(WebhookMessage $webhook): WebhookDelivery 'delivery_id' => $webhook->deliveryId, 'url' => $redactedUrl, 'status_code' => $result->statusCode, - 'error' => $result->error, + 'failure_category' => $result->successful + ? null + : (ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error'), 'duration_ms' => $delivery->metadata['duration_ms'] ?? null, - 'attempt' => $attempt, + 'attempt' => $completedAttempts, + 'cancelled' => $cancelled, ], ); @@ -166,7 +203,22 @@ public function signingSecret(#[\SensitiveParameter] string $secret): self throw new InvalidArgumentException('Webhook signing secret must not be empty.'); } - return new self($this->httpClient, $secret, $this->signer, $this->retryProfile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper); + return new self($this->httpClient, $secret, $this->signer, $this->retryProfile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper, $this->cancellation); + } + + public function withCancellation(?CancellationSignal $cancellation): self + { + return new self( + $this->httpClient, + $this->signingSecret, + $this->signer, + $this->retryProfile, + $this->maxPayloadBytes, + $this->events, + $this->clock, + $this->sleeper, + $cancellation, + ); } public function withRetryProfile( @@ -176,7 +228,7 @@ public function withRetryProfile( ): self { $profile = WebhookRetryProfile::standard($attempts, $baseDelayMs, $maxRetryAfterSeconds); - return new self($this->httpClient, $this->signingSecret, $this->signer, $profile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper); + return new self($this->httpClient, $this->signingSecret, $this->signer, $profile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper, $this->cancellation); } public function withSecret(#[\SensitiveParameter] string $secret): self @@ -186,7 +238,23 @@ public function withSecret(#[\SensitiveParameter] string $secret): self public function withSigner(WebhookSigner $signer): self { - return new self($this->httpClient, $this->signingSecret, $signer, $this->retryProfile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper); + return new self($this->httpClient, $this->signingSecret, $signer, $this->retryProfile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper, $this->cancellation); + } + + private function cancelledResult(?CommunicationResult $previous, int $attempts): CommunicationResult + { + $metadata = $previous === null ? [] : $previous->metadata; + + return CommunicationResult::failure( + 'Webhook delivery cancelled.', + $previous?->statusCode, + $previous?->response, + [ + ...$metadata, + 'cancelled' => true, + 'attempts' => $attempts, + ], + ); } private function signature(string $payload, int $timestamp): string diff --git a/src/Webhook/WebhookVerifier.php b/src/Webhook/WebhookVerifier.php index 4b2174e..0509344 100644 --- a/src/Webhook/WebhookVerifier.php +++ b/src/Webhook/WebhookVerifier.php @@ -8,6 +8,7 @@ use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; use Infocyph\TalkingBytes\Core\Support\Clock; +use Infocyph\TalkingBytes\Webhook\Model\WebhookSignature; use Infocyph\TalkingBytes\Webhook\Model\WebhookVerificationResult; use Infocyph\TalkingBytes\Webhook\Signing\HmacWebhookSigner; use Infocyph\TalkingBytes\Webhook\Signing\WebhookSignatureParser; @@ -69,6 +70,8 @@ public function verifyResult( string $signatureHeader, ?string $timestampHeader = null, ?int $now = null, + ?string $event = null, + ?string $deliveryId = null, ): WebhookVerificationResult { $now ??= (int) floor($this->clock->timestamp()); @@ -84,7 +87,12 @@ public function verifyResult( return $this->reject('missing_timestamp'); } - $parsed = $this->signatureParser->parse($signatureHeader, $timestampHeader); + $version = 'v1'; + if ($event !== null || $deliveryId !== null) { + $payload = WebhookSignature::deliveryPayload($payload, $event ?? '', $deliveryId ?? ''); + $version = 'v2'; + } + $parsed = $this->signatureParser->parse($signatureHeader, $timestampHeader, $version); if ($parsed === null) { if ($timestampHeader !== null && trim($timestampHeader) !== '' && !ctype_digit(trim($timestampHeader))) { return $this->reject('invalid_timestamp'); diff --git a/tests/BounceParserTest.php b/tests/BounceParserTest.php index 9a47d3f..cc0d2dc 100644 --- a/tests/BounceParserTest.php +++ b/tests/BounceParserTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher; use Infocyph\TalkingBytes\Email\Enum\BounceType; -use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus as EmailEventBus; use Infocyph\TalkingBytes\Email\Parser\BounceParser; use Infocyph\TalkingBytes\Email\Parser\DeliveryStatusParser; use Infocyph\TalkingBytes\Email\Parser\RawEmailParser; @@ -82,10 +82,6 @@ it('dispatches bounce.detected event when bounce is parsed', function (): void { $events = []; - EmailEventBus::listen(static function (string $event, array $payload) use (&$events): void { - $events[] = ['event' => $event, 'payload' => $payload]; - }); - $raw = implode("\r\n", [ 'From: postmaster@example.com', 'To: sender@example.com', @@ -96,7 +92,10 @@ ]); $email = (new RawEmailParser)->parse($raw); - $report = (new BounceParser)->parse($email); + $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void { + $events[] = ['event' => $event, 'payload' => $payload]; + }); + $report = (new BounceParser(events: $dispatcher))->parse($email); expect($report)->not->toBeNull(); expect($events)->not->toBeEmpty(); diff --git a/tests/CharsetDecoderTest.php b/tests/CharsetDecoderTest.php index bc172e2..cf81b09 100644 --- a/tests/CharsetDecoderTest.php +++ b/tests/CharsetDecoderTest.php @@ -5,11 +5,12 @@ use Infocyph\TalkingBytes\Email\Parser\CharsetDecoder; it('decodes common charset aliases to utf8', function (): void { + $decoder = new CharsetDecoder; if (! function_exists('iconv') && ! function_exists('mb_convert_encoding')) { - $this->markTestSkipped('charset conversion extensions are unavailable.'); - } + expect($decoder->toUtf8('plain', 'ISO-8859-1'))->toBe('plain'); - $decoder = new CharsetDecoder; + return; + } $cases = [ ['charset' => 'ISO-8859-1', 'text' => 'Café'], @@ -37,28 +38,32 @@ it('uses configured fallback charset when source charset is unknown', function (): void { if (! function_exists('iconv') && ! function_exists('mb_convert_encoding')) { - $this->markTestSkipped('charset conversion extensions are unavailable.'); + expect((new CharsetDecoder('WINDOWS-1252'))->toUtf8('plain', 'X-UNKNOWN-CHARSET'))->toBe('plain'); + + return; } $source = function_exists('iconv') ? iconv('UTF-8', 'WINDOWS-1252//IGNORE', 'Résumé') : mb_convert_encoding('Résumé', 'WINDOWS-1252', 'UTF-8'); + $decoder = new CharsetDecoder('WINDOWS-1252'); if (! is_string($source) || $source === '') { - $this->markTestSkipped('Unable to prepare fallback charset test payload.'); - } + expect($decoder->toUtf8('plain', 'X-UNKNOWN-CHARSET'))->toBe('plain'); - $decoder = new CharsetDecoder('WINDOWS-1252'); + return; + } expect($decoder->toUtf8($source, 'X-UNKNOWN-CHARSET'))->toBe('Résumé'); }); it('supports additional charset aliases and handles invalid byte payloads safely', function (): void { + $decoder = new CharsetDecoder('WINDOWS-1252'); if (! function_exists('iconv') && ! function_exists('mb_convert_encoding')) { - $this->markTestSkipped('charset conversion extensions are unavailable.'); - } + expect($decoder->toUtf8("\xFF\xFE\xFA", 'X-UNKNOWN'))->toBeString(); - $decoder = new CharsetDecoder('WINDOWS-1252'); + return; + } $cases = [ ['charset' => 'CP850', 'text' => 'Cafe'], ['charset' => 'GB18030', 'text' => '中文'], @@ -84,3 +89,37 @@ $decoded = $decoder->toUtf8($invalidBytes, 'X-UNKNOWN'); expect($decoded)->toBeString(); }); + +it('restores the error handler stack and original error mask after charset fallback', function (): void { + $outer = static fn(): bool => true; + $calls = []; + $inner = static function (int $severity) use (&$calls): bool { + $calls[] = $severity; + return true; + }; + $reporting = error_reporting(E_USER_WARNING); + set_error_handler($outer); + set_error_handler($inner, E_USER_WARNING); + try { + for ($i = 0; $i < 3; $i++) { + (new CharsetDecoder())->toUtf8('text', 'INVALID-CHARSET-AUDIT'); + } + trigger_error('excluded notice', E_USER_NOTICE); + trigger_error('included warning', E_USER_WARNING); + restore_error_handler(); + $after = set_error_handler($outer); + restore_error_handler(); + } finally { + error_reporting($reporting); + // Unwind even a broken implementation so the regression cannot pollute other tests. + for ($i = 0; $i < 20; $i++) { + $current = set_error_handler($outer); + restore_error_handler(); + restore_error_handler(); + if ($current === $outer) { + break; + } + } + } + expect($after)->toBe($outer)->and($calls)->toBe([E_USER_WARNING]); +}); diff --git a/tests/EmailOutboundExtensionsTest.php b/tests/EmailOutboundExtensionsTest.php index ad0bfd0..3f11e80 100644 --- a/tests/EmailOutboundExtensionsTest.php +++ b/tests/EmailOutboundExtensionsTest.php @@ -164,7 +164,9 @@ public function send(EmailMessage $message): CommunicationResult expect($events[0]['event'])->toBe('email.send.start'); expect($events[1]['event'])->toBe('email.send.finish'); expect($events[1]['context']['successful'])->toBeFalse(); - expect($events[1]['context']['error'])->toBe('transport boom'); + expect($events[1]['context']['failure_category'] ?? null)->toBe('exception'); + expect($events[1]['context']['exception_class'] ?? null)->toBe(RuntimeException::class); + expect(json_encode($events, JSON_THROW_ON_ERROR))->not->toContain('transport boom'); }); it('fails clearly when log transport directory path is not a directory', function (): void { diff --git a/tests/Fixtures/concurrent-http-server.php b/tests/Fixtures/concurrent-http-server.php new file mode 100644 index 0000000..9ebb8e1 --- /dev/null +++ b/tests/Fixtures/concurrent-http-server.php @@ -0,0 +1,133 @@ + $delayMs) { + if (is_string($path) && is_int($delayMs)) { + $delays[$path] = $delayMs; + } +} + +$server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); +if ($server === false) { + file_put_contents($reportPath, json_encode(['error' => sprintf('%s (%d)', $errstr, $errno)])); + + throw new RuntimeException('Unable to bind concurrent HTTP fixture server.'); +} + +stream_set_blocking($server, false); +$address = stream_socket_get_name($server, false); +if (!is_string($address) || !str_contains($address, ':')) { + fclose($server); + + throw new RuntimeException('Unable to resolve concurrent HTTP fixture server address.'); +} + +$port = (int) substr(strrchr($address, ':'), 1); +file_put_contents($readyPath, json_encode(['port' => $port], JSON_THROW_ON_ERROR)); + +$startedAt = microtime(true); +$deadline = $startedAt + 5.0; +$expected = count($delays); +$completed = 0; +$requestTimes = []; + +/** + * @var array $clients + */ +$clients = []; + +while ($completed < $expected && microtime(true) < $deadline) { + $read = [$server]; + foreach ($clients as $client) { + if ($client['path'] === null) { + $read[] = $client['stream']; + } + } + + $write = []; + $except = []; + stream_select($read, $write, $except, 0, 10_000); + + foreach ($read as $stream) { + if ($stream === $server) { + while (($client = stream_socket_accept($server, 0)) !== false) { + stream_set_blocking($client, false); + $clients[(int) $client] = [ + 'stream' => $client, + 'buffer' => '', + 'path' => null, + 'due' => null, + ]; + } + + continue; + } + + $id = (int) $stream; + if (!isset($clients[$id])) { + continue; + } + + $chunk = fread($stream, 8192); + if (!is_string($chunk) || $chunk === '') { + continue; + } + + $clients[$id]['buffer'] .= $chunk; + if ($clients[$id]['path'] !== null || !str_contains($clients[$id]['buffer'], "\r\n\r\n")) { + continue; + } + + if (preg_match('/^[A-Z]+\\s+(\\S+)\\s+HTTP\\/\\d(?:\\.\\d)?/D', $clients[$id]['buffer'], $matches) !== 1) { + continue; + } + + $path = parse_url($matches[1], PHP_URL_PATH); + $path = is_string($path) && $path !== '' ? $path : '/'; + $delayMs = $delays[$path] ?? 0; + + $clients[$id]['path'] = $path; + $clients[$id]['due'] = microtime(true) + ($delayMs / 1000); + $requestTimes[$path] = (int) round((microtime(true) - $startedAt) * 1000); + } + + $now = microtime(true); + foreach ($clients as $id => $client) { + if ($client['due'] === null || $client['due'] > $now) { + continue; + } + + $body = $client['path'] ?? '/'; + $response = "HTTP/1.1 200 OK\r\n" + . 'Content-Type: text/plain' . "\r\n" + . 'Content-Length: ' . strlen($body) . "\r\n" + . 'Connection: close' . "\r\n\r\n" + . $body; + + fwrite($client['stream'], $response); + fclose($client['stream']); + unset($clients[$id]); + $completed++; + } +} + +foreach ($clients as $client) { + fclose($client['stream']); +} + +fclose($server); +file_put_contents($reportPath, json_encode([ + 'request_times' => $requestTimes, + 'completed' => $completed, +], JSON_THROW_ON_ERROR)); diff --git a/tests/GrpcGeneratedStubInvokerTest.php b/tests/GrpcGeneratedStubInvokerTest.php index 930e0e4..dadc7ee 100644 --- a/tests/GrpcGeneratedStubInvokerTest.php +++ b/tests/GrpcGeneratedStubInvokerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; use Infocyph\TalkingBytes\Grpc\GrpcClient; use Infocyph\TalkingBytes\Grpc\GrpcMetadata; use Infocyph\TalkingBytes\Grpc\Sender\GrpcRequest; @@ -177,6 +178,7 @@ static function (mixed $message) use (&$serverChunks): void { expect($unary->successful)->toBeTrue() ->and($unary->response)->toBeInstanceOf(GrpcResponse::class) ->and($unary->response->message['id'])->toBe(1001) + ->and($unary->response->trailers->first('x-trailer'))->toBe('create-end') ->and($server->successful)->toBeTrue() ->and($serverChunks)->toHaveCount(2) ->and($clientOnly->successful)->toBeTrue() @@ -218,3 +220,194 @@ public function wait(): array expect($result->successful)->toBeTrue() ->and($result->response->message['ok'])->toBeTrue(); }); + + +it('does not reinvoke a stream method when the stub throws an internal type error', function (): void { + $stub = new class { + public int $calls = 0; + + public function Upload(array $metadata = [], array $options = []): object + { + unset($metadata, $options); + $this->calls++; + + throw new TypeError('internal generated stub type error'); + } + }; + + $client = GrpcClient::usingGeneratedStub($stub); + $result = $client->clientStream( + method: 'Orders/Upload', + messages: [['id' => 1]], + ); + + expect($result->successful)->toBeFalse(); + expect($stub->calls)->toBe(1); + expect($result->error)->toContain('internal generated stub type error'); +}); + +it('resolves three argument stream open shape before invocation', function (): void { + $stub = new class { + /** @var list> */ + public array $captures = []; + + public function Upload(mixed $message = null, array $metadata = [], array $options = []): object + { + $this->captures[] = [ + 'message' => $message, + 'metadata' => $metadata, + 'options' => $options, + ]; + + return new class { + public function wait(): array + { + return [['ok' => true], ['code' => 0]]; + } + + public function writesDone(): void {} + + public function write(mixed $message): void + { + unset($message); + } + }; + } + }; + + $result = GrpcClient::usingGeneratedStub($stub)->clientStream( + method: 'Orders/Upload', + messages: [['id' => 1]], + headers: (new GrpcMetadata())->withValue('x-upload', 'yes'), + deadlineSeconds: 1.0, + ); + + expect($result->successful)->toBeTrue(); + expect($stub->captures)->toHaveCount(1); + expect($stub->captures[0]['message'])->toBeNull(); + expect($stub->captures[0]['metadata']['x-upload'][0] ?? null)->toBe('yes'); + expect($stub->captures[0]['options']['timeout'] ?? null)->toBe(1_000_000); +}); + +it('validates explicit generated method maps at adapter construction', function (): void { + $stub = new class { + public function Create(mixed $message, array $metadata = [], array $options = []): object + { + unset($message, $metadata, $options); + + return new class { + public function wait(): array + { + return [['ok' => true], ['code' => 0]]; + } + }; + } + }; + + expect(fn() => new GeneratedStubGrpcInvoker($stub, [ + '/orders.v1.OrderService/Create' => 'Missing', + ]))->toThrow(InvalidArgumentException::class, 'was not found'); + + expect(fn() => new GeneratedStubGrpcInvoker($stub, [ + ' orders.v1.OrderService/Create' => 'Create', + ]))->toThrow(InvalidArgumentException::class, 'surrounding whitespace'); +}); + +it('cancels generated client streams between outbound writes', function (): void { + $state = (object) ['requested' => false]; + + $stub = new class($state) { + public ?object $call = null; + + public function __construct(private object $state) {} + + public function Upload(array $metadata = [], array $options = []): object + { + unset($metadata, $options); + $state = $this->state; + + return $this->call = new class($state) { + public bool $cancelled = false; + + public int $writes = 0; + + public function __construct(private object $state) {} + + public function cancel(): void + { + $this->cancelled = true; + } + + public function wait(): array + { + return [['ok' => true], ['code' => 0]]; + } + + public function writesDone(): void {} + + public function write(mixed $message): void + { + unset($message); + $this->writes++; + $this->state->requested = true; + } + }; + } + }; + + $signal = CancellationSignal::fromCallable(static fn(): bool => $state->requested); + $result = GrpcClient::usingGeneratedStub($stub, cancellation: $signal)->clientStream( + method: 'Orders/Upload', + messages: [['id' => 1], ['id' => 2]], + ); + + expect($result->successful)->toBeFalse(); + expect($stub->call)->not->toBeNull(); + expect($stub->call?->writes)->toBe(1); + expect($stub->call?->cancelled)->toBeTrue(); + expect($result->error)->toContain('cancelled'); +}); + +it('cancels the native stream when a response callback fails', function (): void { + $stub = new class { + public ?object $call = null; + + public function List(mixed $message, array $metadata = [], array $options = []): object + { + unset($message, $metadata, $options); + + return $this->call = new class { + public bool $cancelled = false; + + public function cancel(): void + { + $this->cancelled = true; + } + + public function responses(): iterable + { + yield ['row' => 1]; + yield ['row' => 2]; + } + + public function wait(): array + { + return [null, ['code' => 0]]; + } + }; + } + }; + + $result = GrpcClient::usingGeneratedStub($stub)->serverStream( + new GrpcRequest('Orders/List', ['page' => 1]), + static function (mixed $message): void { + unset($message); + + throw new RuntimeException('consumer callback failed'); + }, + ); + + expect($result->successful)->toBeFalse(); + expect($stub->call?->cancelled)->toBeTrue(); + expect($result->error)->toContain('consumer callback failed'); +}); diff --git a/tests/GrpcServerTest.php b/tests/GrpcServerTest.php index 5ae6958..22ddc6a 100644 --- a/tests/GrpcServerTest.php +++ b/tests/GrpcServerTest.php @@ -2,14 +2,18 @@ declare(strict_types=1); -use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus; use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundExchange; use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundHandlerInterface; use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundRequest; use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundResponse; +use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundSource; use Infocyph\TalkingBytes\Grpc\GrpcMetadata; use Infocyph\TalkingBytes\Grpc\GrpcInboundDispatcher; use Infocyph\TalkingBytes\Grpc\GrpcStatus; +use Infocyph\TalkingBytes\Grpc\Testing\FakeGrpcInboundExchange; +use Infocyph\TalkingBytes\Grpc\Testing\FakeGrpcInboundSource; it('handles inbound grpc requests with registered handlers', function (): void { $server = GrpcInboundDispatcher::new()->withHandler( @@ -81,10 +85,14 @@ static function (): GrpcInboundResponse { $response = $server->receive('/orders.v1.OrderService/Create', ['order_id' => 1]); expect($response->status)->toBe(GrpcStatus::Internal) - ->and($response->metadata['exception'] ?? null)->toBe(RuntimeException::class) + ->and($response->message)->toBe('Inbound gRPC handler failed.') + ->and($response->metadata)->toBe([]) ->and($events)->toHaveCount(2) ->and($events[0][0])->toBe('grpc.inbound.start') - ->and($events[1][0])->toBe('grpc.inbound.failed'); + ->and($events[1][0])->toBe('grpc.inbound.failed') + ->and($events[1][1]['exception'] ?? null)->toBe(RuntimeException::class) + ->and(json_encode($response, JSON_THROW_ON_ERROR))->not->toContain('handler exploded') + ->and(json_encode($response, JSON_THROW_ON_ERROR))->not->toContain(RuntimeException::class); }); it('validates inbound grpc request method and deadline', function (): void { @@ -92,3 +100,79 @@ static function (): GrpcInboundResponse { expect(fn() => new GrpcInboundRequest('Service', ['x' => 1]))->toThrow(InvalidArgumentException::class); expect(fn() => new GrpcInboundRequest('/Svc/Call', ['x' => 1], deadlineSeconds: 0.0))->toThrow(InvalidArgumentException::class); }); + + +it('serves one inbound grpc exchange through a host-controlled source', function (): void { + $source = new FakeGrpcInboundSource(); + $exchange = $source->enqueue(new GrpcInboundRequest( + '/orders.v1.OrderService/Create', + ['order_id' => 44], + )); + $server = GrpcInboundDispatcher::new()->withHandler( + '/orders.v1.OrderService/Create', + static fn(GrpcInboundRequest $request): GrpcInboundResponse => GrpcInboundResponse::ok([ + 'order_id' => $request->message['order_id'] ?? null, + 'accepted' => true, + ]), + ); + + $served = $server->serveOne($source); + + expect($served)->toBeTrue() + ->and($source->pendingCount())->toBe(0) + ->and($source->accepted())->toHaveCount(1) + ->and($exchange->completed())->toBeTrue() + ->and($exchange->response()?->status)->toBe(GrpcStatus::Ok) + ->and($exchange->response()?->message['order_id'] ?? null)->toBe(44); +}); + +it('does not accept another inbound grpc exchange after host cancellation', function (): void { + $source = new FakeGrpcInboundSource(); + $source->enqueue(new GrpcInboundRequest('/orders.v1.OrderService/Create', ['order_id' => 45])); + + $served = GrpcInboundDispatcher::new()->serveOne( + $source, + CancellationSignal::fromCallable(static fn(): bool => true), + ); + + expect($served)->toBeFalse() + ->and($source->pendingCount())->toBe(1) + ->and($source->accepted())->toBe([]); +}); + +it('completes an already accepted grpc exchange as cancelled when host cancellation arrives', function (): void { + $exchange = new FakeGrpcInboundExchange( + new GrpcInboundRequest('/orders.v1.OrderService/Create', ['order_id' => 46]), + ); + $source = new class($exchange) implements GrpcInboundSource { + public function __construct(private readonly GrpcInboundExchange $exchange) {} + + public function accept(?CancellationSignal $cancellation = null): ?GrpcInboundExchange + { + unset($cancellation); + + return $this->exchange; + } + }; + $checks = 0; + $cancellation = CancellationSignal::fromCallable(static function () use (&$checks): bool { + $checks++; + + return $checks > 1; + }); + + $served = GrpcInboundDispatcher::new()->serveOne($source, $cancellation); + + expect($served)->toBeTrue() + ->and($exchange->completed())->toBeTrue() + ->and($exchange->response()?->status)->toBe(GrpcStatus::Cancelled) + ->and($exchange->response()?->message)->toBe('Inbound gRPC call cancelled.'); +}); + +it('prevents fake grpc inbound exchanges from completing twice', function (): void { + $exchange = new FakeGrpcInboundExchange(new GrpcInboundRequest('/Svc/Call', ['ok' => true])); + $exchange->complete(GrpcInboundResponse::ok(['ok' => true])); + + expect(fn() => $exchange->complete(GrpcInboundResponse::ok())) + ->toThrow(LogicException::class, 'already been completed'); +}); diff --git a/tests/HttpConcurrentPoolTest.php b/tests/HttpConcurrentPoolTest.php index fccdc42..50aa654 100644 --- a/tests/HttpConcurrentPoolTest.php +++ b/tests/HttpConcurrentPoolTest.php @@ -3,12 +3,166 @@ declare(strict_types=1); use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; use Infocyph\TalkingBytes\Http\Concurrent\CurlMultiTransport; use Infocyph\TalkingBytes\Http\Concurrent\PoolResult; use Infocyph\TalkingBytes\Http\HttpClient; use Infocyph\TalkingBytes\Http\HttpRequest; use Infocyph\TalkingBytes\Http\Transport\CurlTransport; + +final class ConcurrentHttpTestServer +{ + /** + * @param array $pipes + */ + private function __construct( + private mixed $process, + private array $pipes, + private string $workDir, + public int $port, + ) {} + + public function __destruct() + { + $this->stop(); + } + + /** + * @param array $delays + */ + public static function start(array $delays): self + { + $workDir = sys_get_temp_dir() . '/talkingbytes-http-multi-' . bin2hex(random_bytes(6)); + mkdir($workDir, 0775, true); + + $scenarioPath = $workDir . '/scenario.json'; + $readyPath = $workDir . '/ready.json'; + $reportPath = $workDir . '/report.json'; + file_put_contents($scenarioPath, json_encode($delays, JSON_THROW_ON_ERROR)); + + $process = proc_open( + [PHP_BINARY, __DIR__ . '/Fixtures/concurrent-http-server.php', $scenarioPath, $readyPath, $reportPath], + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + + if (!is_resource($process)) { + self::cleanupDirectory($workDir); + + throw new RuntimeException('Unable to start concurrent HTTP test server.'); + } + + if (is_resource($pipes[0] ?? null)) { + fclose($pipes[0]); + $pipes[0] = null; + } + + $port = self::waitForReadyPort($process, $pipes, $readyPath); + + return new self($process, $pipes, $workDir, $port); + } + + /** + * @return array + */ + public function requestTimes(): array + { + $reportPath = $this->workDir . '/report.json'; + $deadline = microtime(true) + 1.0; + + while (!is_file($reportPath) && microtime(true) < $deadline) { + usleep(10_000); + } + + $decoded = is_file($reportPath) + ? json_decode((string) file_get_contents($reportPath), true) + : null; + $times = is_array($decoded) ? ($decoded['request_times'] ?? []) : []; + + if (!is_array($times)) { + return []; + } + + $normalized = []; + foreach ($times as $path => $milliseconds) { + if (is_string($path) && is_int($milliseconds)) { + $normalized[$path] = $milliseconds; + } + } + + return $normalized; + } + + public function stop(): void + { + foreach ([1, 2] as $index) { + if (is_resource($this->pipes[$index] ?? null)) { + fclose($this->pipes[$index]); + $this->pipes[$index] = null; + } + } + + if (is_resource($this->process)) { + $status = proc_get_status($this->process); + if ($status['running']) { + proc_terminate($this->process); + } + + proc_close($this->process); + $this->process = null; + } + + self::cleanupDirectory($this->workDir); + } + + private static function cleanupDirectory(string $directory): void + { + foreach (glob($directory . '/*') ?: [] as $path) { + if (is_file($path)) { + unlink($path); + } + } + + if (is_dir($directory)) { + rmdir($directory); + } + } + + /** + * @param array $pipes + */ + private static function waitForReadyPort(mixed $process, array $pipes, string $readyPath): int + { + $deadline = microtime(true) + 2.0; + + while (microtime(true) < $deadline) { + if (is_file($readyPath)) { + $decoded = json_decode((string) file_get_contents($readyPath), true); + $port = is_array($decoded) ? ($decoded['port'] ?? null) : null; + if (is_int($port) && $port > 0) { + return $port; + } + } + + $status = proc_get_status($process); + if (!$status['running']) { + break; + } + + usleep(10_000); + } + + $stderr = is_resource($pipes[2] ?? null) ? stream_get_contents($pipes[2]) : ''; + + throw new RuntimeException('Concurrent HTTP test server did not start: ' . $stderr); + } +} + it('preserves request keys in concurrent pool results', function (): void { $requests = [ 'users' => HttpRequest::get('https://example.com/users')->blockHosts(['example.com']), @@ -72,3 +226,91 @@ expect($singleResult->error)->toContain('cannot combine uploadFromFile/uploadFromStream'); expect($multiResult?->error)->toContain('cannot combine uploadFromFile/uploadFromStream'); }); + + +it('stops admitting new requests immediately after a preparation failure', function (): void { + $pool = HttpClient::multi(maxConcurrency: 3)->stopSchedulingOnFailure(); + + $result = $pool->sendMany([ + 'first' => HttpRequest::get('https://example.com/first')->blockHosts(['example.com']), + 'second' => HttpRequest::get('https://example.com/second')->blockHosts(['example.com']), + 'third' => HttpRequest::get('https://example.com/third')->blockHosts(['example.com']), + ]); + + expect(array_keys($result->all()))->toBe(['first']); + expect($result->metadata['stopped_scheduling'] ?? null)->toBeTrue(); + expect($result->metadata['cancelled'] ?? null)->toBeFalse(); +}); + +it('returns deterministic cancelled results before pool scheduling starts', function (): void { + $pool = HttpClient::multi(maxConcurrency: 2) + ->withCancellation(CancellationSignal::fromCallable(static fn(): bool => true)); + + $result = $pool->sendMany([ + 'first' => HttpRequest::get('https://example.com/first'), + 'second' => HttpRequest::get('https://example.com/second'), + 'third' => HttpRequest::get('https://example.com/third'), + ]); + + expect(array_keys($result->all()))->toBe(['first', 'second', 'third']); + expect($result->metadata['stopped_scheduling'] ?? null)->toBeTrue(); + expect($result->metadata['cancelled'] ?? null)->toBeTrue(); + + foreach ($result->all() as $cancelled) { + expect($cancelled->successful)->toBeFalse(); + expect($cancelled->metadata['cancelled'] ?? null)->toBeTrue(); + expect($cancelled->metadata['started'] ?? null)->toBeFalse(); + } +}); + + +it('refills the rolling window as soon as a fast request completes', function (): void { + $server = ConcurrentHttpTestServer::start([ + '/slow' => 700, + '/fast-one' => 100, + '/fast-two' => 10, + ]); + + try { + $baseUrl = sprintf('http://127.0.0.1:%d', $server->port); + $result = HttpClient::multi(maxConcurrency: 2)->sendMany([ + 'slow' => HttpRequest::get($baseUrl . '/slow'), + 'fast-one' => HttpRequest::get($baseUrl . '/fast-one'), + 'fast-two' => HttpRequest::get($baseUrl . '/fast-two'), + ]); + + expect($result->successfulCount())->toBe(3); + expect(array_keys($result->all()))->toBe(['slow', 'fast-one', 'fast-two']); + + $times = $server->requestTimes(); + expect($times)->toHaveKeys(['/slow', '/fast-one', '/fast-two']); + expect($times['/fast-two'] - $times['/slow'])->toBeLessThan(350); + } finally { + $server->stop(); + } +}); + +it('cancels active curl handles cooperatively', function (): void { + $server = ConcurrentHttpTestServer::start(['/slow' => 1000]); + + try { + $baseUrl = sprintf('http://127.0.0.1:%d', $server->port); + $startedAt = microtime(true); + $cancellation = CancellationSignal::fromCallable( + static fn(): bool => (microtime(true) - $startedAt) >= 0.1, + ); + + $result = HttpClient::multi(maxConcurrency: 1, cancellation: $cancellation)->sendMany([ + 'slow' => HttpRequest::get($baseUrl . '/slow'), + ]); + + $cancelled = $result->get('slow'); + expect($cancelled)->toBeInstanceOf(CommunicationResult::class); + expect($cancelled?->successful)->toBeFalse(); + expect($cancelled?->metadata['cancelled'] ?? null)->toBeTrue(); + expect($cancelled?->metadata['started'] ?? null)->toBeTrue(); + expect($result->metadata['cancelled'] ?? null)->toBeTrue(); + } finally { + $server->stop(); + } +}); diff --git a/tests/HttpStreamingTest.php b/tests/HttpStreamingTest.php index 1acd8ce..313da8b 100644 --- a/tests/HttpStreamingTest.php +++ b/tests/HttpStreamingTest.php @@ -224,3 +224,16 @@ } fclose($stream); }); + + +it('aborts streamed downloads without publishing partial files', function (): void { + $target = sys_get_temp_dir() . '/tb-http-stream-' . bin2hex(random_bytes(6)) . '.txt'; + $request = HttpRequest::get('https://example.com')->streamDownloadTo($target); + $collector = new ResponseBodyCollector($request); + + expect($collector->collect('partial'))->toBe(7); + $collector->abort(); + + expect(is_file($target))->toBeFalse(); + expect($collector->collect('late'))->toBe(0); +}); diff --git a/tests/MailboxCommandRedactorTest.php b/tests/MailboxCommandRedactorTest.php index 9629bda..eff0ebd 100644 --- a/tests/MailboxCommandRedactorTest.php +++ b/tests/MailboxCommandRedactorTest.php @@ -7,7 +7,7 @@ it('redacts sensitive imap login and authenticate commands', function (): void { expect(MailboxCommandRedactor::redact('imap', 'LOGIN "user" "secret"')) - ->toBe('LOGIN "user" [REDACTED]'); + ->toBe('LOGIN [REDACTED] [REDACTED]'); expect(MailboxCommandRedactor::redact('imap', 'AUTHENTICATE PLAIN dXNlcgB1c2VyAHNlY3JldA==')) ->toBe('AUTHENTICATE [REDACTED]'); @@ -21,13 +21,13 @@ ->toBe('PASS [REDACTED]'); expect(MailboxCommandRedactor::redact('pop3', 'APOP user deadbeef123')) - ->toBe('APOP user [REDACTED]'); + ->toBe('APOP [REDACTED] [REDACTED]'); expect(MailboxCommandRedactor::redact('pop3', 'AUTH PLAIN dGVzdA==')) ->toBe('AUTH [REDACTED]'); expect(MailboxCommandRedactor::redact('pop3', 'USER test')) - ->toBe('USER test'); + ->toBe('USER [REDACTED]'); }); it('dispatches redacted IMAP command payloads in start and finish events', function (): void { @@ -44,8 +44,8 @@ expect($events[0]['event'] ?? null)->toBe('mailbox.command.start'); expect($events[1]['event'] ?? null)->toBe('mailbox.command.finish'); - expect($events[0]['command'] ?? '')->toBe('LOGIN "user" [REDACTED]'); - expect($events[1]['command'] ?? '')->toBe('LOGIN "user" [REDACTED]'); + expect($events[0]['command'] ?? '')->toBe('LOGIN [REDACTED] [REDACTED]'); + expect($events[1]['command'] ?? '')->toBe('LOGIN [REDACTED] [REDACTED]'); }); it('dispatches redacted IMAP AUTHENTICATE payloads in mailbox events', function (): void { @@ -88,8 +88,8 @@ expect($events[1]['event'] ?? null)->toBe('mailbox.command.finish'); expect($events[0]['command'] ?? '')->toBe('PASS [REDACTED]'); expect($events[1]['command'] ?? '')->toBe('PASS [REDACTED]'); - expect($events[2]['command'] ?? '')->toBe('APOP user [REDACTED]'); - expect($events[3]['command'] ?? '')->toBe('APOP user [REDACTED]'); + expect($events[2]['command'] ?? '')->toBe('APOP [REDACTED] [REDACTED]'); + expect($events[3]['command'] ?? '')->toBe('APOP [REDACTED] [REDACTED]'); expect($events[4]['command'] ?? '')->toBe('AUTH [REDACTED]'); expect($events[5]['command'] ?? '')->toBe('AUTH [REDACTED]'); }); diff --git a/tests/MailboxImapTest.php b/tests/MailboxImapTest.php index 221f712..68762cc 100644 --- a/tests/MailboxImapTest.php +++ b/tests/MailboxImapTest.php @@ -2,8 +2,9 @@ declare(strict_types=1); +use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; use Infocyph\TalkingBytes\Email\Config\ImapConfig; -use Infocyph\TalkingBytes\Email\Email; use Infocyph\TalkingBytes\Email\Enum\ImapSecurity; use Infocyph\TalkingBytes\Email\Exception\MailboxConnectionException; use Infocyph\TalkingBytes\Email\Exception\MailboxProtocolException; @@ -394,6 +395,23 @@ private static function script(): string ))->toBeFalse(); }); +it('exposes explicit mailbox session lifecycle and cancellation helpers', function (): void { + $fake = FakeMailbox::new(); + $events = 0; + + $fake->mailbox->connect(); + $fake->mailbox->watchUntilCancelled( + 'INBOX', + static function () use (&$events): void { + $events++; + }, + CancellationSignal::fromCallable(static fn(): bool => true), + ); + $fake->mailbox->logout(); + + expect($events)->toBe(0); +}); + it('supports fake mailbox operations and parsing workflow', function (): void { $fake = FakeMailbox::new(); @@ -677,7 +695,7 @@ static function (string $event) use (&$events, &$done): void { it('redacts IMAP LOGIN password in mailbox command events', function (): void { $events = []; - Email::events(static function (string $event, array $payload) use (&$events): void { + $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void { if (str_starts_with($event, 'mailbox.command.')) { $events[] = $payload; } @@ -692,26 +710,29 @@ static function (string $event) use (&$events, &$done): void { ], ]); - $mailbox = Mailbox::usingImap(new ImapConfig( - host: '127.0.0.1', - port: $server->port, - security: ImapSecurity::None, - username: 'user', - password: 'pass', - )); + $mailbox = Mailbox::usingImap( + new ImapConfig( + host: '127.0.0.1', + port: $server->port, + security: ImapSecurity::None, + username: 'user', + password: 'pass', + ), + events: $dispatcher, + ); $mailbox->folders(); $mailbox->transport()->logout(); $server->stop(); - Email::events(null); expect(array_any( $events, - static fn (array $payload): bool => ($payload['command'] ?? null) === 'LOGIN "user" [REDACTED]', + static fn (array $payload): bool => ($payload['command'] ?? null) === 'LOGIN [REDACTED] [REDACTED]', ))->toBeTrue(); expect(array_any( $events, - static fn (array $payload): bool => is_string($payload['command'] ?? null) && str_contains($payload['command'], 'LOGIN "user" "pass"'), + static fn (array $payload): bool => is_string($payload['command'] ?? null) + && (str_contains($payload['command'], '"user"') || str_contains($payload['command'], '"pass"')), ))->toBeFalse(); }); diff --git a/tests/MonotonicTransportTimingTest.php b/tests/MonotonicTransportTimingTest.php new file mode 100644 index 0000000..7af8075 --- /dev/null +++ b/tests/MonotonicTransportTimingTest.php @@ -0,0 +1,157 @@ + 1_700_000_000.0, + static function () use (&$now): float { + $current = $now; + $now += 0.25; + + return $current; + }, + ); + $events = []; + $dispatcher = new CallableEventDispatcher( + static function (string $event, array $payload) use (&$events): void { + $events[$event] = $payload; + }, + ); + + $result = (new CurlTransport($dispatcher, $clock))->send( + HttpRequest::get('https://example.test')->blockHosts(['example.test']), + ); + + expect($result->successful)->toBeFalse() + ->and($events['http.request.failed']['duration_ms'] ?? null)->toBe(250); +}); + +it('uses the injected monotonic clock for unary gRPC duration', function (): void { + $now = 20.0; + $clock = new Clock( + static fn(): float => 1_700_000_000.0, + static function () use (&$now): float { + $current = $now; + $now += 0.125; + + return $current; + }, + ); + $events = []; + $dispatcher = new CallableEventDispatcher( + static function (string $event, array $payload) use (&$events): void { + $events[$event] = $payload; + }, + ); + $transport = new GrpcTransport( + static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse(GrpcStatus::Ok, $request->message), + $dispatcher, + $clock, + ); + + $result = $transport->send(new GrpcRequest('/runtime.v1.Health/Check', ['ok' => true])); + + expect($result->successful)->toBeTrue() + ->and($events['grpc.request.finish']['duration_ms'] ?? null)->toBe(125); +}); + +it('preserves the injected monotonic clock across gRPC immutable streaming graphs', function (): void { + $now = 30.0; + $clock = new Clock( + static fn(): float => 1_700_000_000.0, + static function () use (&$now): float { + $current = $now; + $now += 0.25; + + return $current; + }, + ); + $events = []; + $dispatcher = new CallableEventDispatcher( + static function (string $event, array $payload) use (&$events): void { + $events[$event] = $payload; + }, + ); + + $invoker = new class implements NativeGrpcInvoker, NativeGrpcStreamingInvoker { + public function invoke( + string $method, + mixed $message, + GrpcMetadata $headers, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + unset($method, $headers, $deadlineSeconds); + + return new NativeGrpcResult(GrpcStatus::Ok->value, $message); + } + + public function bidiStream( + string $method, + iterable $messages, + GrpcMetadata $headers, + callable $onMessage, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + unset($method, $headers, $deadlineSeconds); + + foreach ($messages as $message) { + $onMessage($message); + } + + return new NativeGrpcResult(GrpcStatus::Ok->value); + } + + public function clientStream( + string $method, + iterable $messages, + GrpcMetadata $headers, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + unset($method, $headers, $deadlineSeconds); + + foreach ($messages as $message) { + unset($message); + } + + return new NativeGrpcResult(GrpcStatus::Ok->value); + } + + public function serverStream( + string $method, + mixed $message, + GrpcMetadata $headers, + callable $onMessage, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + unset($method, $headers, $deadlineSeconds); + + $onMessage($message); + + return new NativeGrpcResult(GrpcStatus::Ok->value); + } + }; + + $client = GrpcClient::usingNativeStreaming($invoker, $invoker, $dispatcher, $clock) + ->withMiddlewares([]); + + $result = $client->clientStream('/runtime.v1.Health/Stream', [['ok' => true]]); + + expect($result->successful)->toBeTrue() + ->and($events['grpc.stream.finish']['duration_ms'] ?? null)->toBe(250); +}); diff --git a/tests/MutableStateIsolationTest.php b/tests/MutableStateIsolationTest.php new file mode 100644 index 0000000..1098e2f --- /dev/null +++ b/tests/MutableStateIsolationTest.php @@ -0,0 +1,96 @@ +withDefaultHeaders(['X-Profile' => 'derived']) + ->withBearerToken('derived-token'); + + $base->get('https://example.test/base'); + $derived->get('https://example.test/derived'); + + $requests = $transport->sentRequests(); + + expect($requests)->toHaveCount(2) + ->and($requests[0]->headers->get('X-Profile'))->toBeNull() + ->and($requests[0]->headers->get('Authorization'))->toBeNull() + ->and($requests[1]->headers->get('X-Profile'))->toBe('derived') + ->and($requests[1]->headers->get('Authorization'))->toBe('Bearer derived-token'); +}); + +it('keeps cookie jars isolated by instance', function (): void { + $first = new CookieJar(); + $second = new CookieJar(); + + $first->remember(new Cookie('session', 'first', 'example.test')); + + expect($first->count())->toBe(1) + ->and($second->count())->toBe(0) + ->and($second->all())->toBe([]); +}); + +it('keeps circuit breaker state isolated by instance', function (): void { + $first = new CircuitBreaker(failureThreshold: 1); + $second = new CircuitBreaker(failureThreshold: 1); + + $first->onFailure(); + + expect($first->state())->toBe(CircuitState::Open) + ->and($second->state())->toBe(CircuitState::Closed); +}); + +it('keeps rate limiter token state isolated by instance', function (): void { + $first = new RateLimiter(1, 60); + $second = new RateLimiter(1, 60); + + $first->assertCanProceed(); + + expect(static fn() => $first->assertCanProceed()) + ->toThrow(RuntimeException::class, 'Rate limit exceeded.'); + + $second->assertCanProceed(); + expect(true)->toBeTrue(); +}); + +it('keeps cookie sessions isolated when persistent-runtime fibers interleave', function (): void { + $transportA = (new FakeHttpTransport()) + ->pushJson(['ok' => true], 200, ['Set-Cookie' => 'session=a; Path=/']) + ->pushJson(['ok' => true], 200); + $transportB = (new FakeHttpTransport()) + ->pushJson(['ok' => true], 200, ['Set-Cookie' => 'session=b; Path=/']) + ->pushJson(['ok' => true], 200); + + $clientA = HttpClient::fake($transportA)->withCookieJar(new CookieJar()); + $clientB = HttpClient::fake($transportB)->withCookieJar(new CookieJar()); + + $fiberA = new Fiber(static function () use ($clientA): void { + $clientA->get('https://a.example.test/login'); + Fiber::suspend(); + $clientA->get('https://a.example.test/orders'); + }); + + $fiberA->start(); + $clientB->get('https://b.example.test/login'); + $clientB->get('https://b.example.test/orders'); + $fiberA->resume(); + + $requestsA = $transportA->sentRequests(); + $requestsB = $transportB->sentRequests(); + + expect((string) $requestsA[1]->headers->get('Cookie'))->toContain('session=a') + ->and((string) $requestsA[1]->headers->get('Cookie'))->not->toContain('session=b') + ->and((string) $requestsB[1]->headers->get('Cookie'))->toContain('session=b') + ->and((string) $requestsB[1]->headers->get('Cookie'))->not->toContain('session=a') + ->and($fiberA->isTerminated())->toBeTrue(); +}); diff --git a/tests/ObservabilitySanitizationTest.php b/tests/ObservabilitySanitizationTest.php new file mode 100644 index 0000000..491da1d --- /dev/null +++ b/tests/ObservabilitySanitizationTest.php @@ -0,0 +1,143 @@ + 'Bearer sentinel-auth', + 'Proxy-Authorization' => 'Basic sentinel-proxy', + 'Cookie' => 'session=sentinel-cookie', + 'X-Api-Key' => 'sentinel-api-key', + 'X-TB-Signature' => 't=1,v1=sentinel-signature', + 'Accept' => 'application/json', + ]); + + expect($headers['Authorization'])->toBe('[REDACTED]') + ->and($headers['Proxy-Authorization'])->toBe('[REDACTED]') + ->and($headers['Cookie'])->toBe('[REDACTED]') + ->and($headers['X-Api-Key'])->toBe('[REDACTED]') + ->and($headers['X-TB-Signature'])->toBe('[REDACTED]') + ->and($headers['Accept'])->toBe('application/json'); +}); + +it('keeps webhook failure events free of raw errors and secrets', function (): void { + $events = []; + $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'webhook.')) { + $events[] = [$event, $payload]; + } + }); + $transport = new SequenceHttpTransport([ + CommunicationResult::failure('sentinel-webhook-error', 503), + ]); + $sender = (new WebhookSender(HttpClient::using($transport), events: $dispatcher)) + ->withSecret('sentinel-webhook-signing-secret'); + + $sender->send( + WebhookMessage::new('order.created') + ->url('https://hooks.example.test/orders?token=sentinel-query-secret') + ->payload(['private' => 'sentinel-body-secret']), + ); + + $encoded = observabilityJson($events); + expect($encoded)->not->toContain('sentinel-webhook-error') + ->and($encoded)->not->toContain('sentinel-webhook-signing-secret') + ->and($encoded)->not->toContain('sentinel-body-secret') + ->and($encoded)->not->toContain('sentinel-query-secret') + ->and($encoded)->toContain('failure_category'); +}); + +it('keeps email events and logging decorators free of subject and raw errors', function (): void { + $events = []; + $logs = []; + $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void { + $events[] = [$event, $payload]; + }); + $transport = new class implements EmailTransport { + public function send(EmailMessage $message): CommunicationResult + { + unset($message); + + return CommunicationResult::failure( + 'sentinel-email-error', + metadata: ['transport' => 'sentinel-transport', 'secret' => 'sentinel-metadata-secret'], + ); + } + }; + $message = EmailMessage::new() + ->from('sender@example.test') + ->to('private-person@example.test') + ->subject('sentinel-private-subject') + ->text('sentinel-private-body'); + + (new Emailer($transport, $dispatcher))->send($message); + (new LoggingEmailTransport( + $transport, + static function (string $event, array $context) use (&$logs): void { + $logs[] = [$event, $context]; + }, + ))->send($message); + + $encoded = observabilityJson([...$events, ...$logs]); + expect($encoded)->not->toContain('sentinel-email-error') + ->and($encoded)->not->toContain('sentinel-private-subject') + ->and($encoded)->not->toContain('private-person@example.test') + ->and($encoded)->not->toContain('sentinel-private-body') + ->and($encoded)->not->toContain('sentinel-metadata-secret') + ->and($encoded)->toContain('failure_category'); +}); + +it('keeps grpc events free of exception messages and metadata values', function (): void { + $events = []; + $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void { + if (str_starts_with($event, 'grpc.')) { + $events[] = [$event, $payload]; + } + }); + $headers = (new GrpcMetadata())->withValue('authorization', 'sentinel-grpc-metadata'); + $client = GrpcClient::using( + static function (GrpcRequest $request): never { + expect($request->headers->first('authorization'))->toBe('sentinel-grpc-metadata'); + throw new RuntimeException('sentinel-grpc-error'); + }, + $dispatcher, + ); + + $client->send(new GrpcRequest('/example.Service/Call', ['secret' => 'sentinel-grpc-body'], $headers)); + + $encoded = observabilityJson($events); + expect($encoded)->not->toContain('sentinel-grpc-error') + ->and($encoded)->not->toContain('sentinel-grpc-metadata') + ->and($encoded)->not->toContain('sentinel-grpc-body') + ->and($encoded)->toContain(RuntimeException::class); +}); + +it('removes mailbox auth usernames and credentials from diagnostics', function (): void { + expect(MailboxCommandRedactor::redact('imap', 'LOGIN "sentinel-user" "sentinel-password"')) + ->toBe('LOGIN [REDACTED] [REDACTED]') + ->and(MailboxCommandRedactor::redact('pop3', 'USER sentinel-user'))->toBe('USER [REDACTED]') + ->and(MailboxCommandRedactor::redact('pop3', 'PASS sentinel-password'))->toBe('PASS [REDACTED]') + ->and(MailboxCommandRedactor::redact('pop3', 'APOP sentinel-user sentinel-digest')) + ->toBe('APOP [REDACTED] [REDACTED]'); +}); diff --git a/tests/OptionalCapabilityColdnessTest.php b/tests/OptionalCapabilityColdnessTest.php new file mode 100644 index 0000000..dbcb027 --- /dev/null +++ b/tests/OptionalCapabilityColdnessTest.php @@ -0,0 +1,169 @@ + OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + + if ($key === false) { + throw new RuntimeException('Unable to generate RSA key for optional-capability coldness test.'); + } + + $privateKey = ''; + if (!openssl_pkey_export($key, $privateKey)) { + throw new RuntimeException('Unable to export RSA key for optional-capability coldness test.'); + } + + return $privateKey; +} + +it('keeps unrelated protocol graphs usable without optional runtime extensions', function (): void { + if (!talkingBytesOptionalColdnessEnabled()) { + expect(true)->toBeTrue(); + + return; + } + + foreach (['grpc', 'imap', 'posix'] as $extension) { + expect(extension_loaded($extension))->toBeFalse(); + } + + expect(class_exists('Grpc\\Channel'))->toBeFalse(); + + $http = HttpClient::using(new FakeHttpTransport()); + expect($http->send(HttpRequest::get('https://example.test/health'))->successful)->toBeTrue(); + + $webhook = Webhook::sender($http)->send( + WebhookMessage::new('runtime.coldness') + ->url('https://example.test/webhook') + ->payload(['ok' => true]), + ); + expect($webhook->result->successful)->toBeTrue(); + + $email = Email::sender()->usingNull(); + expect($email->send( + EmailMessage::new() + ->from('sender@example.test') + ->to('recipient@example.test') + ->subject('Cold runtime') + ->text('ok'), + )->successful)->toBeTrue(); + + $smtp = Email::sender()->usingSmtp(new SmtpConfig('smtp.example.test')); + expect($smtp)->toBeInstanceOf(Emailer::class); + + $grpc = GrpcClient::using( + static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse( + GrpcStatus::Ok, + $request->message, + ), + ); + $grpcResult = $grpc->send(new GrpcRequest('/runtime.v1.Health/Check', ['ok' => true])); + + expect($grpcResult->successful)->toBeTrue() + ->and(class_exists('Grpc\\Channel'))->toBeFalse(); +}); + +it('keeps RSA DKIM independent from Sodium', function (): void { + if (!talkingBytesOptionalColdnessEnabled()) { + expect(true)->toBeTrue(); + + return; + } + + $config = DkimConfig::fromPrivateKeyString( + 'example.test', + 'selector', + talkingBytesColdnessRsaPrivateKey(), + ); + + expect($config->algorithm)->toBe(DkimAlgorithm::RsaSha256); +}); + +it('fails Ed25519 DKIM clearly only when the capability is selected', function (): void { + if (!talkingBytesOptionalColdnessEnabled()) { + expect(true)->toBeTrue(); + + return; + } + + $build = static fn(): DkimConfig => DkimConfig::fromPrivateKeyString( + 'example.test', + 'selector', + base64_encode(random_bytes(32)), + algorithm: DkimAlgorithm::Ed25519Sha256, + ); + + if (!function_exists('sodium_crypto_sign_detached')) { + expect($build)->toThrow(RuntimeException::class, 'Sodium extension is required for Ed25519 DKIM signing.'); + + return; + } + + expect($build()->algorithm)->toBe(DkimAlgorithm::Ed25519Sha256); +}); + + +it('confines compiled-in optional capabilities to their selected feature boundary', function (): void { + $root = dirname(__DIR__) . '/src'; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)); + $pcntlReferences = []; + $unexpectedSodiumReferences = []; + $allowedSodiumFiles = [ + 'Email/Config/DkimConfig.php', + 'Email/Dkim/DkimSigner.php', + 'Email/Dkim/DkimVerifier.php', + 'Email/Dkim/DkimPublicKeyParser.php', + ]; + + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || !$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $contents = file_get_contents($file->getPathname()); + if (!is_string($contents)) { + continue; + } + + $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1)); + if (preg_match('/\\bpcntl_/', $contents) === 1) { + $pcntlReferences[] = $relative; + } + + if ( + preg_match('/\\bsodium_/', $contents) === 1 + && !in_array($relative, $allowedSodiumFiles, true) + ) { + $unexpectedSodiumReferences[] = $relative; + } + } + + expect($pcntlReferences)->toBe([]) + ->and($unexpectedSodiumReferences)->toBe([]); +}); diff --git a/tests/Pop3MailboxTest.php b/tests/Pop3MailboxTest.php index 1b7a458..ad2968b 100644 --- a/tests/Pop3MailboxTest.php +++ b/tests/Pop3MailboxTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher; use Infocyph\TalkingBytes\Email\Config\Pop3Config; -use Infocyph\TalkingBytes\Email\Email; use Infocyph\TalkingBytes\Email\Enum\Pop3Security; use Infocyph\TalkingBytes\Email\Exception\MailboxAuthenticationException; use Infocyph\TalkingBytes\Email\Exception\MailboxConnectionException; @@ -384,13 +384,12 @@ private static function waitForReadyPort(string $readyPath, mixed $process, arra expect($transcript['mismatches'])->toBe([]); }); -it('fails pop3 authentication when server rejects password', function (): void { +it('fails pop3 authentication and drops the poisoned connection', function (): void { $server = FakePop3ServerProcess::start([ 'expect' => [ ['regex' => '/^CAPA$/', 'multiline' => ['UIDL']], ['regex' => '/^USER user$/'], ['regex' => '/^PASS wrong$/', 'status' => '-ERR', 'text' => 'invalid login'], - ['regex' => '/^QUIT$/'], ], ]); @@ -404,8 +403,12 @@ private static function waitForReadyPort(string $readyPath, mixed $process, arra expect(fn() => $mailbox->status())->toThrow(MailboxAuthenticationException::class); + $transcript = $server->transcript(); $mailbox->logout(); $server->stop(); + + expect($transcript['mismatches'])->toBe([]); + expect($transcript['commands'])->toBe(['CAPA', 'USER user', 'PASS wrong']); }); it('rejects unsupported pop3 folder operations and non-all searches', function (): void { @@ -525,7 +528,7 @@ private static function waitForReadyPort(string $readyPath, mixed $process, arra it('redacts POP3 PASS value in mailbox command events', function (): void { $events = []; - Email::events(static function (string $event, array $payload) use (&$events): void { + $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void { if (str_starts_with($event, 'mailbox.command.')) { $events[] = $payload; } @@ -541,18 +544,20 @@ private static function waitForReadyPort(string $readyPath, mixed $process, arra ], ]); - $mailbox = Pop3Mailbox::usingConfig(new Pop3Config( - host: '127.0.0.1', - port: $server->port, - security: Pop3Security::None, - username: 'user', - password: 'pass', - )); + $mailbox = Pop3Mailbox::usingConfig( + new Pop3Config( + host: '127.0.0.1', + port: $server->port, + security: Pop3Security::None, + username: 'user', + password: 'pass', + ), + events: $dispatcher, + ); $mailbox->status(); $mailbox->logout(); $server->stop(); - Email::events(null); expect(array_any( $events, diff --git a/tests/ResolvedCompositionTest.php b/tests/ResolvedCompositionTest.php new file mode 100644 index 0000000..458ba75 --- /dev/null +++ b/tests/ResolvedCompositionTest.php @@ -0,0 +1,224 @@ + 7, + 'auth' => [ + 'driver' => 'bearer', + 'token' => 'resolved-token', + ], + 'cookies' => ['enabled' => true], + 'retry' => [ + 'enabled' => true, + 'attempts' => 2, + 'base_delay_ms' => 0, + 'max_retry_after_seconds' => 1, + ], + 'rate_limit' => [ + 'enabled' => true, + 'max_requests' => 10, + 'per_seconds' => 60, + ], + 'circuit_breaker' => [ + 'enabled' => true, + 'failure_threshold' => 3, + 'cool_down_seconds' => 5, + ], + 'idempotency' => [ + 'enabled' => true, + 'header' => 'Idempotency-Key', + ], + ], transport: $transport); + + $result = $client->send(HttpRequest::post('https://example.test/orders')->json(['id' => 1])); + $request = $transport->sentRequests()[0]; + + expect($result->successful)->toBeTrue() + ->and($client->hasRetryMiddleware())->toBeTrue() + ->and($request->headers->get('Authorization'))->toBe('Bearer resolved-token') + ->and($request->headers->get('Idempotency-Key'))->toMatch('/^[a-f0-9]{32}$/') + ->and($request->options->timeoutSeconds)->toBe(7); +}); + +it('parses email limits and composes resolved sender transport decorators', function (): void { + $limits = EmailLimits::fromArray([ + 'maxMessageBytes' => '2048', + 'maxAttachmentBytes' => 4096, + 'maxHeaderLineBytes' => '900', + ]); + + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + expect($key)->not->toBeFalse(); + + $privateKey = ''; + expect(openssl_pkey_export($key, $privateKey))->toBeTrue(); + + $emailer = (new EmailSenderFactory())->fromResolvedConfig([ + 'transport' => ['driver' => 'null'], + 'fallbacks' => [ + ['driver' => 'fake'], + ], + 'retry' => [ + 'enabled' => true, + 'policy' => 'fixed', + 'max_attempts' => 2, + 'delay_ms' => 0, + ], + 'rate_limit' => [ + 'enabled' => true, + 'max_requests' => 10, + 'per_seconds' => 60, + ], + 'dkim' => [ + 'enabled' => true, + 'domain' => 'example.test', + 'selector' => 'mail', + 'private_key' => $privateKey, + 'headers' => ['from', 'to', 'subject'], + ], + ]); + + $result = $emailer->send( + EmailMessage::new() + ->from('sender@example.test') + ->to('recipient@example.test') + ->subject('Resolved sender') + ->text('ok'), + ); + + expect($limits->maxMessageBytes)->toBe(2048) + ->and($limits->maxAttachmentBytes)->toBe(4096) + ->and($limits->maxHeaderLineBytes)->toBe(900) + ->and($emailer->transport())->toBeInstanceOf(DkimSigningTransport::class) + ->and($result->successful)->toBeTrue(); +}); + +it('builds grpc retry and generated stub clients from resolved protocol config', function (): void { + $attempts = 0; + $client = (new GrpcClientFactory())->using( + static function (GrpcRequest $request) use (&$attempts): GrpcResponse { + unset($request); + $attempts++; + + return new GrpcResponse( + status: $attempts === 1 ? GrpcStatus::Unavailable : GrpcStatus::Ok, + message: ['attempt' => $attempts], + ); + }, + [ + 'retry' => [ + 'enabled' => true, + 'attempts' => 2, + 'base_delay_ms' => 0, + 'jitter_ratio' => 0, + ], + ], + ); + + $result = $client->send( + (new GrpcRequest('/orders.v1.OrderService/Create', ['id' => 1])) + ->withRetrySafety(), + ); + + $stub = new class { + public function Ping(mixed $message, array $metadata = [], array $options = []): object + { + unset($metadata, $options); + + return new class($message) { + public function __construct(private readonly mixed $message) {} + + public function wait(): array + { + return [['echo' => $this->message], ['code' => 0]]; + } + }; + } + }; + + $generated = GrpcClient::usingGeneratedStub($stub); + $generatedResult = $generated->send(new GrpcRequest('/example.PingService/Ping', ['ping' => true])); + + expect($result->successful)->toBeTrue() + ->and($attempts)->toBe(2) + ->and($generatedResult->successful)->toBeTrue() + ->and($generatedResult->response->message['echo']['ping'] ?? false)->toBeTrue(); +}); + +it('builds webhook signing retry and replay-aware receiver policies from resolved config', function (): void { + $transport = new SequenceHttpTransport([ + CommunicationResult::failure( + 'temporary', + 503, + new HttpResponse(503, '{"retry":true}'), + ), + CommunicationResult::success( + 200, + new HttpResponse(200, '{"ok":true}'), + ), + ]); + + $sender = Webhook::senderFromResolvedConfig( + HttpClient::using($transport), + [ + 'signing_secret' => 'whsec_resolved', + 'retry' => [ + 'enabled' => true, + 'attempts' => 2, + 'base_delay_ms' => 0, + 'max_retry_after_seconds' => 1, + ], + ], + ); + + $delivery = $sender->send( + WebhookMessage::new('order.created') + ->url('https://hooks.example.test/orders') + ->payload(['order_id' => 7]), + ); + + $requests = $transport->sentRequests(); + $receiver = Webhook::receiverFromResolvedConfig( + 'whsec_resolved', + [ + 'max_age_seconds' => 300, + 'replay' => [ + 'enabled' => true, + 'ttl_seconds' => 60, + 'namespace' => 'resolved', + ], + ], + new InMemoryWebhookReplayStore(), + ); + + expect($delivery->result->successful)->toBeTrue() + ->and($requests)->toHaveCount(2) + ->and($requests[0]->headers->get(WebhookHeaders::SIGNATURE))->not->toBeNull() + ->and($receiver)->toBeInstanceOf(\Infocyph\TalkingBytes\Webhook\WebhookReceiver::class); +}); diff --git a/tests/RuntimeIsolationAndCancellationTest.php b/tests/RuntimeIsolationAndCancellationTest.php new file mode 100644 index 0000000..2d4ef71 --- /dev/null +++ b/tests/RuntimeIsolationAndCancellationTest.php @@ -0,0 +1,130 @@ +parse($raw); +} + +it('keeps injected runtime events independent from the compatibility static bus', function (): void { + $globalEvents = []; + $localEvents = []; + + CommunicationEventBus::listen(static function (string $event) use (&$globalEvents): void { + $globalEvents[] = $event; + }); + + try { + $parser = new BounceParser(events: new CallableEventDispatcher( + static function (string $event) use (&$localEvents): void { + $localEvents[] = $event; + }, + )); + + $report = $parser->parse(runtimeIsolationBounceEmail()); + + expect($report?->type)->toBe(BounceType::MailboxFull) + ->and($localEvents)->toBe(['bounce.detected']) + ->and($globalEvents)->toBe([]); + } finally { + CommunicationEventBus::listen(null); + } +}); + +it('keeps injected event graphs isolated when Fibers interleave', function (): void { + $eventsA = []; + $eventsB = []; + $email = runtimeIsolationBounceEmail(); + + $parserA = new BounceParser(events: new CallableEventDispatcher( + static function (string $event) use (&$eventsA): void { + $eventsA[] = $event; + \Fiber::suspend(); + }, + )); + $parserB = new BounceParser(events: new CallableEventDispatcher( + static function (string $event) use (&$eventsB): void { + $eventsB[] = $event; + }, + )); + + $fiber = new \Fiber(static fn() => $parserA->parse($email)); + $fiber->start(); + + expect($fiber->isSuspended())->toBeTrue(); + + $reportB = $parserB->parse($email); + $fiber->resume(); + + expect($reportB?->type)->toBe(BounceType::MailboxFull) + ->and($fiber->isTerminated())->toBeTrue() + ->and($eventsA)->toBe(['bounce.detected']) + ->and($eventsB)->toBe(['bounce.detected']); +}); + +it('interrupts retry waiting when cancellation is requested', function (): void { + $attempts = 0; + $sleptMicroseconds = []; + $sleeper = new Sleeper(static function (int $microseconds) use (&$sleptMicroseconds): void { + $sleptMicroseconds[] = $microseconds; + }); + $cancellation = new CancellationSignal(static function () use (&$attempts): bool { + return $attempts >= 1; + }); + + $result = RetryExecutor::run( + new FixedDelayRetryPolicy(3, 250), + static function () use (&$attempts): CommunicationResult { + $attempts++; + + return CommunicationResult::failure('temporary', 503); + }, + $sleeper, + $cancellation, + ); + + expect($result->successful)->toBeFalse() + ->and($result->metadata['cancelled'] ?? false)->toBeTrue() + ->and($result->metadata['attempts'] ?? null)->toBe(1) + ->and($attempts)->toBe(1) + ->and($sleptMicroseconds)->toBe([]); +}); + +it('checks cancellation between bounded sleep slices', function (): void { + $checks = 0; + $slices = []; + $sleeper = new Sleeper(static function (int $microseconds) use (&$slices): void { + $slices[] = $microseconds; + }); + $cancellation = new CancellationSignal(static function () use (&$checks): bool { + $checks++; + + return $checks >= 3; + }); + + $completed = $sleeper->millisecondsInterruptibly(500, $cancellation, 50); + + expect($completed)->toBeFalse() + ->and($slices)->toBe([50_000, 50_000]); +}); diff --git a/tests/RuntimeSoakTest.php b/tests/RuntimeSoakTest.php new file mode 100644 index 0000000..5276e45 --- /dev/null +++ b/tests/RuntimeSoakTest.php @@ -0,0 +1,56 @@ + new GrpcResponse( + GrpcStatus::Ok, + $request->message, + ), + ); + + if ($iteration % 25 === 0) { + $references[] = WeakReference::create($http); + $references[] = WeakReference::create($email); + $references[] = WeakReference::create($replay); + $references[] = WeakReference::create($grpc); + } + + unset($http, $email, $replay, $grpc); + } + + gc_collect_cycles(); + + foreach ($references as $reference) { + expect($reference->get())->toBeNull(); + } +}); + +it('starts mutable test and replay state clean on every new graph', function (): void { + for ($iteration = 0; $iteration < 250; $iteration++) { + $transport = new FakeHttpTransport(); + $client = HttpClient::using($transport); + $replay = new InMemoryWebhookReplayStore(2); + + expect($transport->sentRequests())->toBe([]) + ->and($replay->claim('soak', 'delivery', 60))->toBeTrue(); + + unset($client, $transport, $replay); + } +}); diff --git a/tests/TransportProcessCoverageTest.php b/tests/TransportProcessCoverageTest.php index 374978b..9e85b8a 100644 --- a/tests/TransportProcessCoverageTest.php +++ b/tests/TransportProcessCoverageTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; use Infocyph\TalkingBytes\Email\Config\SendmailConfig; use Infocyph\TalkingBytes\Email\Config\SpoolConfig; use Infocyph\TalkingBytes\Email\EmailMessage; @@ -21,6 +22,25 @@ expect($result->error)->toContain('simulated failure'); }); +it('cancels a running sendmail process cooperatively', function (): void { + $script = createSendmailTestScript(); + $checks = 0; + $cancellation = CancellationSignal::fromCallable(static function () use (&$checks): bool { + $checks++; + + return $checks > 1; + }); + $transport = new SendmailTransport( + new SendmailConfig($script, ['sleep', '5'], 10), + cancellation: $cancellation, + ); + + $result = $transport->send(testMessage()); + + expect($result->successful)->toBeFalse(); + expect($result->error)->toContain('cancelled'); +}); + it('fails sendmail transport on timeout', function (): void { $script = createSendmailTestScript(); $transport = new SendmailTransport(new SendmailConfig($script, ['sleep', '2'], 1)); diff --git a/tests/WebhookEventRedactionTest.php b/tests/WebhookEventRedactionTest.php index bf9cbb4..f9e49da 100644 --- a/tests/WebhookEventRedactionTest.php +++ b/tests/WebhookEventRedactionTest.php @@ -37,7 +37,13 @@ $signatureHeader = (string) $sent[0]->headers->get(WebhookHeaders::SIGNATURE); // Trigger verifier events as well. - Webhook::verifier($secret, events: $dispatcher)->verifyResult($rawPayload, $signatureHeader); + $verified = Webhook::verifier($secret, events: $dispatcher)->verifyResult( + $rawPayload, + $signatureHeader, + event: (string) $sent[0]->headers->get(WebhookHeaders::EVENT), + deliveryId: (string) $sent[0]->headers->get(WebhookHeaders::DELIVERY), + ); + expect($verified->valid)->toBeTrue(); Webhook::verifier($secret, events: $dispatcher)->verifyResult($rawPayload, 't=1,v1=not-a-real-signature'); expect($delivery->result->successful)->toBeTrue() diff --git a/tests/WebhookReceiverTest.php b/tests/WebhookReceiverTest.php index 1f2dbd5..6b750ac 100644 --- a/tests/WebhookReceiverTest.php +++ b/tests/WebhookReceiverTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Infocyph\TalkingBytes\Webhook\Contracts\WebhookReplayStore; use Infocyph\TalkingBytes\Webhook\Replay\InMemoryWebhookReplayStore; use Infocyph\TalkingBytes\Webhook\Testing\WebhookTestFactory; use Infocyph\TalkingBytes\Webhook\Webhook; @@ -40,7 +41,7 @@ $invalidJson = '{'; $invalidJsonHeaders = $headers; $invalidJsonHeaders['X-TB-Signature'] = (new WebhookSignature('whsec_test')) - ->buildHeader($invalidJson, (int) $headers['X-TB-Timestamp']); + ->buildHeader($invalidJson, (int) $headers['X-TB-Timestamp'], $headers['X-TB-Event'], $headers['X-TB-Delivery']); expect(fn() => $receiver->receive($invalidJson, $invalidJsonHeaders)) ->toThrow(InvalidArgumentException::class, 'Webhook payload must be valid JSON.'); @@ -98,3 +99,125 @@ expect(fn() => $receiver->receive($payload, $spacedEvent)) ->toThrow(InvalidArgumentException::class, 'surrounding whitespace'); }); + + +it('claims each replay identity only once and validates replay inputs', function (): void { + $store = new InMemoryWebhookReplayStore(); + + $claims = [ + $store->claim('tenant-a', 'evt_contention', 60), + $store->claim('tenant-a', 'evt_contention', 60), + ]; + + expect(array_values(array_filter($claims)))->toHaveCount(1); + expect(fn() => $store->claim('tenant-a', 'evt_ttl', 0)) + ->toThrow(InvalidArgumentException::class, 'TTL must be greater than zero'); + + $receiver = (new WebhookReceiver(Webhook::verifier('whsec_test'))) + ->withReplayStore($store, 60); + + expect(fn() => $receiver->withReplayStore($store, 60, '')) + ->toThrow(InvalidArgumentException::class, 'namespace must not be empty'); +}); + +it('verifies webhook signatures before touching replay state', function (): void { + [$payload, $headers] = WebhookTestFactory::signedJson( + secret: 'whsec_test', + event: 'order.created', + payload: ['id' => 2], + deliveryId: 'evt_verify_first', + ); + + $store = new class implements WebhookReplayStore { + public int $claims = 0; + + public function claim(string $namespace, string $deliveryId, int $ttlSeconds): bool + { + unset($namespace, $deliveryId, $ttlSeconds); + $this->claims++; + + return true; + } + }; + + $receiver = (new WebhookReceiver(Webhook::verifier('whsec_test'))) + ->withReplayStore($store, 60); + + $headers['X-TB-Signature'] = 't=1,v1=invalid'; + + expect(fn() => $receiver->receive($payload, $headers)) + ->toThrow(RuntimeException::class, 'Webhook verification failed'); + expect($store->claims)->toBe(0); +}); + +it('fails closed when the replay backend cannot claim a verified delivery', function (): void { + [$payload, $headers] = WebhookTestFactory::signedJson( + secret: 'whsec_test', + event: 'order.created', + payload: ['id' => 3], + deliveryId: 'evt_backend_failure', + ); + + $store = new class implements WebhookReplayStore { + public function claim(string $namespace, string $deliveryId, int $ttlSeconds): bool + { + unset($namespace, $deliveryId, $ttlSeconds); + + throw new RuntimeException('replay backend unavailable'); + } + }; + + $receiver = (new WebhookReceiver(Webhook::verifier('whsec_test'))) + ->withReplayStore($store, 60); + + expect(fn() => $receiver->receive($payload, $headers)) + ->toThrow(RuntimeException::class, 'replay backend unavailable'); +}); + +it('authenticates delivery identity and event before claiming replay state', function (): void { + [$payload, $headers] = WebhookTestFactory::signedJson('secret', 'order.created', ['id' => 1], 'original'); + $receiver = Webhook::receiver('secret')->withReplayStore(new InMemoryWebhookReplayStore()); + + foreach (['X-TB-Delivery' => 'altered', 'X-TB-Event' => 'order.refunded'] as $name => $value) { + $tampered = $headers; + $tampered[$name] = $value; + expect(fn() => $receiver->receive($payload, $tampered))->toThrow(RuntimeException::class, 'signature_mismatch'); + } + + expect($receiver->receive($payload, $headers)->deliveryId)->toBe('original'); + expect(fn() => $receiver->receive($payload, $headers))->toThrow(RuntimeException::class, 'already been processed'); + $headers['X-TB-Delivery'] = 'replayed'; + expect(fn() => $receiver->receive($payload, $headers))->toThrow(RuntimeException::class, 'signature_mismatch'); +}); + +it('rejects legacy signature downgrade even alongside an invalid bound signature', function (): void { + [$payload, $headers] = WebhookTestFactory::signedJson('secret', 'order.created', ['id' => 1]); + $legacy = (new WebhookSignature('secret'))->buildHeader($payload, (int) $headers['X-TB-Timestamp']); + $receiver = Webhook::receiver('secret'); + $headers['X-TB-Signature'] = $legacy; + expect(fn() => $receiver->receive($payload, $headers))->toThrow(RuntimeException::class, 'malformed_signature'); + $headers['X-TB-Signature'] .= ',v2=' . str_repeat('0', 64); + expect(fn() => $receiver->receive($payload, $headers))->toThrow(RuntimeException::class, 'signature_mismatch'); +}); + +it('accepts bound signatures across secret rotation and rejects payload and timestamp tampering', function (): void { + [$payload, $headers] = WebhookTestFactory::signedJson('old-secret', 'order.created', ['id' => 1]); + $receiver = new WebhookReceiver(new \Infocyph\TalkingBytes\Webhook\WebhookVerifier(['new-secret', 'old-secret'])); + expect($receiver->receive($payload, $headers)->payload)->toBe(['id' => 1]); + expect(fn() => $receiver->receive('{"id":2}', $headers))->toThrow(RuntimeException::class, 'signature_mismatch'); + $headers['X-TB-Timestamp'] = (string) ((int) $headers['X-TB-Timestamp'] + 1); + expect(fn() => $receiver->receive($payload, $headers))->toThrow(RuntimeException::class, 'signature_mismatch'); +}); + +it('receives the signed request produced by the native sender', function (): void { + $transport = new \Infocyph\TalkingBytes\Http\Testing\FakeHttpTransport(); + Webhook::sender(\Infocyph\TalkingBytes\Http\HttpClient::using($transport))->withSecret('secret')->send( + \Infocyph\TalkingBytes\Webhook\WebhookMessage::event('order.created')->url('https://example.test/hook')->payload(['id' => 1]), + ); + $request = $transport->sentRequests()[0]; + $headers = []; + foreach (['X-TB-Event', 'X-TB-Delivery', 'X-TB-Timestamp', 'X-TB-Signature'] as $name) { + $headers[$name] = (string) $request->headers->get($name); + } + expect(Webhook::receiver('secret')->receive('{"id":1}', $headers)->event)->toBe('order.created'); +}); diff --git a/tests/WebhookSenderTest.php b/tests/WebhookSenderTest.php index fc36b18..2f63cb4 100644 --- a/tests/WebhookSenderTest.php +++ b/tests/WebhookSenderTest.php @@ -5,6 +5,8 @@ use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus; use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Http\HttpClient; use Infocyph\TalkingBytes\Http\HttpResponse; use Infocyph\TalkingBytes\Http\Testing\SequenceHttpTransport; @@ -112,6 +114,65 @@ ->and((string) ($events[1]['payload']['signature'] ?? ''))->toBe(''); }); + +it('stops before the first webhook attempt when cancellation is already requested', function (): void { + $transport = new SequenceHttpTransport([ + CommunicationResult::success(200, new HttpResponse(200, '{"ok":true}')), + ]); + $sender = WebhookSender::usingHttp(HttpClient::using($transport)) + ->withCancellation(CancellationSignal::fromCallable(static fn(): bool => true)); + + $delivery = $sender->send( + WebhookMessage::new('order.created') + ->payload(['order_id' => 1001]) + ->url('https://hooks.example.test/orders'), + ); + + expect($transport->sentRequests())->toBe([]) + ->and($delivery->result->successful)->toBeFalse() + ->and($delivery->result->metadata['cancelled'] ?? false)->toBeTrue() + ->and($delivery->delivery?->attempts)->toBe(0) + ->and($delivery->delivery?->metadata['cancelled'] ?? false)->toBeTrue(); +}); + +it('interrupts webhook retry waiting when cancellation is requested', function (): void { + $cancelled = false; + $transport = new SequenceHttpTransport([ + CommunicationResult::failure( + 'HTTP request failed with status code 500.', + 500, + new HttpResponse(500, '{"error":true}'), + ), + CommunicationResult::success(200, new HttpResponse(200, '{"ok":true}')), + ]); + $sleeper = new Sleeper(static function () use (&$cancelled): void { + $cancelled = true; + }); + $signal = CancellationSignal::fromCallable(static function () use (&$cancelled): bool { + return $cancelled; + }); + $sender = WebhookSender::usingHttpWithRetryProfile( + HttpClient::using($transport), + attempts: 2, + baseDelayMs: 250, + sleeper: $sleeper, + cancellation: $signal, + ); + + $delivery = $sender->send( + WebhookMessage::new('order.created') + ->payload(['order_id' => 1001]) + ->url('https://hooks.example.test/orders'), + ); + + expect($transport->sentRequests())->toHaveCount(1) + ->and($delivery->result->successful)->toBeFalse() + ->and($delivery->result->statusCode)->toBe(500) + ->and($delivery->result->metadata['cancelled'] ?? false)->toBeTrue() + ->and($delivery->delivery?->attempts)->toBe(1) + ->and($delivery->delivery?->metadata['cancelled'] ?? false)->toBeTrue(); +}); + it('rejects overriding reserved webhook headers', function (): void { expect(fn() => WebhookMessage::new('order.created')->header('X-TB-Event', 'override')) ->toThrow(InvalidArgumentException::class);