260 lines
9.0 KiB
PHP
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;
|
|
}
|
|
}
|