TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellOutputGuard.php
ahmido f4e342121a feat: add Exchange PowerShell production runner gate (#499)
Spec 432: Exchange PowerShell production runner boundary and runtime gate. Validation: php artisan test --filter=Spec432 --compact; ./vendor/bin/pint --dirty --test --format agent; git diff --check.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #499
2026-07-07 18:34:18 +00:00

159 lines
5.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Support\Providers\ProviderReasonCodes;
final class ExchangePowerShellOutputGuard
{
public function guard(
ExchangePowerShellProcessResult $result,
ExchangePowerShellCommandContract $contract,
ExchangePowerShellRuntimePolicy $policy,
): ExchangePowerShellInvocationResult {
if ($result->timedOut) {
return ExchangePowerShellInvocationResult::failed(
reasonCode: ProviderReasonCodes::NetworkUnreachable,
failureCode: 'execution_failed_timeout',
message: 'Exchange PowerShell process timed out.',
summaryCounts: $this->failureSummaryCounts(),
context: [
'output_state' => 'timeout',
'timeout_cleanup_attempted' => true,
'duration_ms' => $result->durationMs,
],
);
}
if ($result->exceptionClass !== null) {
return ExchangePowerShellInvocationResult::failed(
reasonCode: ProviderReasonCodes::UnknownError,
failureCode: 'execution_failed_unexpected_exception',
message: 'Exchange PowerShell process failed safely.',
summaryCounts: $this->failureSummaryCounts(),
context: [
'output_state' => 'executor_exception',
'duration_ms' => $result->durationMs,
],
);
}
if (strlen($result->stdout) > $policy->stdoutMaxBytes) {
return $this->shapeFailure('execution_failed_stdout_oversized', 'stdout_oversized', $result->durationMs);
}
if (strlen($result->stderr) > $policy->stderrMaxBytes) {
return $this->shapeFailure('execution_failed_stderr_oversized', 'stderr_oversized', $result->durationMs);
}
if (! $this->isSafeUtf8Text($result->stdout) || ! $this->isSafeUtf8Text($result->stderr)) {
return $this->shapeFailure('execution_failed_output_encoding', 'non_utf8_or_binary', $result->durationMs);
}
if ($result->exitCode !== 0) {
return ExchangePowerShellInvocationResult::failed(
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
failureCode: 'execution_failed_nonzero_exit',
message: 'Exchange PowerShell process exited unsuccessfully.',
summaryCounts: $this->failureSummaryCounts(),
context: [
'output_state' => 'nonzero_exit',
'duration_ms' => $result->durationMs,
],
);
}
if (trim($result->stderr) !== '') {
return $this->shapeFailure('execution_failed_stderr_present', 'stderr_present', $result->durationMs);
}
$stdout = trim($result->stdout);
if ($stdout === '') {
return ExchangePowerShellInvocationResult::succeeded([], context: [
'output_state' => 'empty_collection',
'item_count' => 0,
'duration_ms' => $result->durationMs,
]);
}
if (preg_match('/\AWARNING:/i', $stdout) === 1) {
return $this->shapeFailure('execution_failed_output_shape_unsafe', 'warning_prefixed', $result->durationMs);
}
$decoded = json_decode($stdout, true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded) || ! array_is_list($decoded)) {
return $this->shapeFailure('execution_failed_shape_unsafe', 'malformed_json_collection', $result->durationMs);
}
$items = [];
foreach ($decoded as $item) {
if (! is_array($item)) {
return $this->shapeFailure('execution_failed_shape_unsafe', 'malformed_collection_item', $result->durationMs);
}
$items[] = $item;
}
if (count($items) > $policy->itemLimit) {
return $this->shapeFailure('execution_failed_item_limit_exceeded', 'item_limit_exceeded', $result->durationMs);
}
return ExchangePowerShellInvocationResult::succeeded($items, context: [
'output_state' => 'structured_collection',
'item_count' => count($items),
'duration_ms' => $result->durationMs,
]);
}
private function shapeFailure(string $failureCode, string $outputState, int $durationMs): ExchangePowerShellInvocationResult
{
return ExchangePowerShellInvocationResult::failed(
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
failureCode: $failureCode,
message: 'Exchange PowerShell output was rejected safely.',
summaryCounts: $this->failureSummaryCounts(),
context: [
'output_state' => $outputState,
'duration_ms' => $durationMs,
],
);
}
private function isSafeUtf8Text(string $value): bool
{
if ($value === '') {
return true;
}
if (function_exists('mb_check_encoding') && ! mb_check_encoding($value, 'UTF-8')) {
return false;
}
if (preg_match('//u', $value) !== 1) {
return false;
}
return preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value) !== 1;
}
/**
* @return array<string, int>
*/
private function failureSummaryCounts(): array
{
return [
'total' => 1,
'processed' => 1,
'succeeded' => 0,
'failed' => 1,
'skipped' => 0,
'items' => 0,
];
}
}