TenantAtlas/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellStructuredOutputEnvelope.php
Ahmed Darrazi 73d84c2706
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m14s
feat: add Exchange structured output readiness
2026-07-08 16:24:20 +02:00

260 lines
9.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final readonly class ExchangePowerShellStructuredOutputEnvelope
{
public const string SHAPE_STRUCTURED_COLLECTION = 'structured_collection';
public const string SHAPE_STRUCTURED_EMPTY_COLLECTION = 'structured_empty_collection';
public const string SHAPE_BLOCKED_UNKNOWN = 'unknown_blocked_output';
public const string OUTPUT_GUARD_ACCEPTED = 'accepted';
public const string OUTPUT_GUARD_BLOCKED = 'blocked';
public const string READINESS_PENDING = 'pending';
public const string READINESS_READY = 'ready';
public const string READINESS_BLOCKED = 'blocked';
/**
* @var list<string>
*/
private const FORBIDDEN_ITEM_KEY_FRAGMENTS = [
'authorization',
'clientsecret',
'cookie',
'credential',
'password',
'providersecret',
'rawproviderpayload',
'rawshellstderr',
'rawshellstdout',
'rawstderr',
'rawstdout',
'rawtranscript',
'refreshtoken',
'secret',
'stderr',
'stdout',
'token',
'transcript',
];
/**
* @param list<array<string, mixed>> $items
*/
private function __construct(
public string $resourceType,
public string $commandContractName,
public string $commandContractVersion,
public string $sourceSurface,
public string $runnerMode,
public string $shapeState,
public array $items,
public int $itemCount,
public bool $emptyCollection,
public bool $warningsClassified,
public string $outputGuardState,
public string $normalizerReadinessState,
public string $redactionReadinessState,
public string $identityReadinessState,
public ?string $readinessBlocker = null,
) {}
public static function fromInvocationResult(
string $resourceType,
ExchangePowerShellCommandContract $contract,
string $runnerMode,
ExchangePowerShellInvocationResult $result,
): self {
$contractPayload = $contract->toArray();
$outputState = self::stringContext($result->context, 'output_state');
$shapeState = self::shapeState($outputState, $result);
$items = self::safeItems($result->collection);
$accepted = $result->successful
&& in_array($shapeState, [self::SHAPE_STRUCTURED_COLLECTION, self::SHAPE_STRUCTURED_EMPTY_COLLECTION], true)
&& ($shapeState === self::SHAPE_STRUCTURED_EMPTY_COLLECTION || $items !== []);
$itemCount = self::intContext($result->context, 'item_count') ?? count($items);
return new self(
resourceType: $resourceType,
commandContractName: (string) ($contractPayload['command_name'] ?? ''),
commandContractVersion: (string) ($contractPayload['command_contract_version'] ?? ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION),
sourceSurface: (string) ($contractPayload['source_surface'] ?? ExchangePowerShellCommandContracts::SOURCE_SURFACE),
runnerMode: trim($runnerMode) !== '' ? trim($runnerMode) : 'unknown',
shapeState: $shapeState,
items: $accepted ? $items : [],
itemCount: max(0, $itemCount),
emptyCollection: $shapeState === self::SHAPE_STRUCTURED_EMPTY_COLLECTION,
warningsClassified: $outputState === 'warning_prefixed',
outputGuardState: $accepted ? self::OUTPUT_GUARD_ACCEPTED : self::OUTPUT_GUARD_BLOCKED,
normalizerReadinessState: $accepted ? self::READINESS_PENDING : self::READINESS_BLOCKED,
redactionReadinessState: $accepted ? self::READINESS_PENDING : self::READINESS_BLOCKED,
identityReadinessState: $accepted ? self::READINESS_PENDING : self::READINESS_BLOCKED,
readinessBlocker: $accepted ? null : self::blockerFor($outputState, $result->failureCode),
);
}
public function accepted(): bool
{
return $this->outputGuardState === self::OUTPUT_GUARD_ACCEPTED;
}
/**
* @return array<string, mixed>
*/
public function safeSummary(): array
{
return [
'resource_type' => $this->resourceType,
'command_contract_name' => $this->commandContractName,
'command_contract_version' => $this->commandContractVersion,
'source_surface' => $this->sourceSurface,
'runner_mode' => $this->runnerMode,
'shape_state' => $this->shapeState,
'item_count' => $this->itemCount,
'empty_collection' => $this->emptyCollection,
'warnings_classified' => $this->warningsClassified,
'output_guard_state' => $this->outputGuardState,
'normalizer_readiness_state' => $this->normalizerReadinessState,
'redaction_readiness_state' => $this->redactionReadinessState,
'identity_readiness_state' => $this->identityReadinessState,
'readiness_blocker' => $this->readinessBlocker,
];
}
private static function shapeState(?string $outputState, ExchangePowerShellInvocationResult $result): string
{
if ($result->successful && $outputState === 'empty_collection') {
return self::SHAPE_STRUCTURED_EMPTY_COLLECTION;
}
if ($result->successful && $outputState === self::SHAPE_STRUCTURED_COLLECTION) {
return self::SHAPE_STRUCTURED_COLLECTION;
}
return match ($outputState) {
'malformed_collection_item' => 'malformed_collection_item',
'malformed_json_collection' => 'malformed_json_collection',
'warning_prefixed' => 'warning_prefixed',
'non_utf8_or_binary' => 'non_utf8_or_binary',
'stdout_oversized' => 'stdout_oversized',
'stderr_oversized' => 'stderr_oversized',
'nonzero_exit' => 'nonzero_exit',
'timeout' => 'timeout',
'executor_exception' => 'executor_exception',
'item_limit_exceeded' => 'item_limit_exceeded',
'stderr_present' => 'stderr_present',
default => self::SHAPE_BLOCKED_UNKNOWN,
};
}
private static function blockerFor(?string $outputState, ?string $failureCode): string
{
return match ($outputState) {
'malformed_collection_item', 'malformed_json_collection' => 'blocked_malformed_output',
'warning_prefixed' => 'blocked_warning_prefixed_output',
'non_utf8_or_binary' => 'blocked_binary_output',
'stdout_oversized', 'stderr_oversized', 'item_limit_exceeded' => 'blocked_oversized_output',
'nonzero_exit' => 'blocked_nonzero_exit',
'timeout' => 'blocked_timeout',
'stderr_present' => 'blocked_stderr_present',
default => $failureCode !== null && $failureCode !== '' ? $failureCode : 'blocked_unknown_output',
};
}
/**
* @return list<array<string, mixed>>
*/
private static function safeItems(mixed $collection): array
{
if (! is_array($collection) || ! array_is_list($collection)) {
return [];
}
$items = [];
foreach ($collection as $item) {
if (! is_array($item) || ($item !== [] && array_is_list($item))) {
return [];
}
$items[] = self::sanitizeItem($item);
}
return $items;
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>
*/
private static function sanitizeItem(array $item): array
{
$sanitized = [];
foreach ($item as $key => $value) {
if (! is_string($key) || self::isForbiddenItemKey($key)) {
continue;
}
$sanitized[$key] = self::sanitizeItemValue($value);
}
return $sanitized;
}
private static function sanitizeItemValue(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_values(array_map(
static fn (mixed $item): mixed => self::sanitizeItemValue($item),
$value,
));
}
return self::sanitizeItem($value);
}
private static function isForbiddenItemKey(string $key): bool
{
$normalized = strtolower((string) preg_replace('/[^a-zA-Z0-9]+/', '', $key));
foreach (self::FORBIDDEN_ITEM_KEY_FRAGMENTS as $fragment) {
if (str_contains($normalized, $fragment)) {
return true;
}
}
return false;
}
private static function stringContext(array $context, string $key): ?string
{
$value = $context[$key] ?? null;
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
private static function intContext(array $context, string $key): ?int
{
$value = $context[$key] ?? null;
if (! is_int($value) && ! (is_string($value) && ctype_digit($value))) {
return null;
}
return (int) $value;
}
}