*/ 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> $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 */ 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> */ 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 $item * @return array */ 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; } }