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
159 lines
5.6 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|