## Summary - add Spec 437 package for Exchange comparable promotion slice 1 - add ExchangePowerShellComparablePayloadBuilder for normalized-payload-only comparable artifacts - add focused Pest unit/feature coverage for comparable safety, determinism, redaction, and no product-surface promotion ## Validation - cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent: PASS - cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec437 --compact: failed with Symfony Process signal 9 - cd apps/platform && php artisan test --filter=Spec437 --compact: PASS, 34 tests / 225 assertions - git diff --check: PASS ## Product Surface / Deployment - UI impact: N/A - no rendered UI surface changed - Browser proof: N/A - no rendered UI surface changed - Destructive/high-impact actions: none - Assets: none; no filament:assets requirement introduced - Deployment impact: no env vars, migrations, queues, scheduler, storage, assets, routes, jobs, listeners, commands, or Dokploy runtime behavior changes Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #504
675 lines
23 KiB
PHP
675 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantConfiguration;
|
|
|
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
|
use App\Support\TenantConfiguration\CoverageLevel;
|
|
use App\Support\TenantConfiguration\EvidenceState;
|
|
use BackedEnum;
|
|
|
|
final class ExchangePowerShellComparablePayloadBuilder
|
|
{
|
|
public const string PAYLOAD_SHAPE_VERSION = 'exchange-powershell-comparable-payload-v1';
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const SUPPORTED_RESOURCE_TYPES = [
|
|
'transportRule',
|
|
'remoteDomain',
|
|
'inboundConnector',
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const UNSAFE_REMOTE_DOMAIN_IDENTITY_FIELDS = [
|
|
'domainname',
|
|
'displayname',
|
|
'name',
|
|
];
|
|
|
|
/**
|
|
* Fields listed here may keep exact normalized values in target sections
|
|
* only when the value matches the expected safe scalar shape. All other
|
|
* values in those sections must already be redacted markers.
|
|
*
|
|
* @var array<string, array<string, array<string, string|array{enum: list<string>}>>>
|
|
*/
|
|
private const SAFE_EXACT_VALUE_POLICIES = [
|
|
'transportRule' => [
|
|
'material' => [
|
|
'enabled' => 'bool',
|
|
'mode' => ['enum' => ['enforce', 'audit', 'auditandnotify']],
|
|
'state' => ['enum' => ['enabled', 'disabled']],
|
|
],
|
|
'rule_order' => [
|
|
'priority' => 'non_negative_integer',
|
|
],
|
|
'conditions' => [],
|
|
'actions' => [
|
|
'delete_message' => 'bool',
|
|
'apply_html_disclaimer_fallback_action' => ['enum' => ['wrap', 'ignore', 'reject']],
|
|
'apply_html_disclaimer_location' => ['enum' => ['append', 'prepend']],
|
|
],
|
|
'exceptions' => [],
|
|
'protected_configuration' => [],
|
|
'sensitive_content' => [],
|
|
],
|
|
'remoteDomain' => [
|
|
'remote_domain_class' => [
|
|
'is_default' => 'bool',
|
|
'domain_name_is_identity' => 'bool',
|
|
],
|
|
'settings' => [
|
|
'auto_reply_enabled' => 'bool',
|
|
],
|
|
'protected_configuration' => [],
|
|
'sensitive_content' => [],
|
|
],
|
|
'inboundConnector' => [
|
|
'material' => [
|
|
'connector_type' => ['enum' => ['partner', 'onpremises']],
|
|
'enabled' => 'bool',
|
|
'require_tls' => 'bool',
|
|
],
|
|
'routing' => [
|
|
'connector_type' => ['enum' => ['partner', 'onpremises']],
|
|
'enabled' => 'bool',
|
|
'require_tls' => 'bool',
|
|
],
|
|
'protected_configuration' => [],
|
|
'sensitive_content' => [],
|
|
],
|
|
];
|
|
|
|
/**
|
|
* These sections are only read through explicit fields in target payload builders.
|
|
* Unknown keys in these sections are ignored by output and therefore do not need
|
|
* leakage blocking.
|
|
*
|
|
* @var array<string, list<string>>
|
|
*/
|
|
private const PARTIAL_EXACT_VALUE_SECTIONS = [
|
|
'transportRule' => [
|
|
'material',
|
|
'rule_order',
|
|
],
|
|
'inboundConnector' => [
|
|
'material',
|
|
],
|
|
];
|
|
|
|
/**
|
|
* @param array<string, mixed> $normalizedPayload
|
|
* @param array<string, mixed> $metadata
|
|
* @return array{comparable: bool, blocker: string|null, resource_type: string, payload: array<string, mixed>|null, comparable_hash?: string}
|
|
*/
|
|
public function build(
|
|
string $resourceType,
|
|
array $normalizedPayload,
|
|
?string $payloadHash = null,
|
|
array $metadata = [],
|
|
): array {
|
|
$resourceType = trim($resourceType);
|
|
|
|
if (! in_array($resourceType, self::SUPPORTED_RESOURCE_TYPES, true)) {
|
|
return $this->blocked($resourceType, 'unsupported_resource_type');
|
|
}
|
|
|
|
if ($normalizedPayload === []) {
|
|
return $this->blocked($resourceType, 'missing_normalized_payload');
|
|
}
|
|
|
|
if (($normalizedPayload['canonical_type'] ?? null) !== $resourceType) {
|
|
return $this->blocked($resourceType, 'normalized_payload_type_mismatch');
|
|
}
|
|
|
|
if (($normalizedPayload['payload_shape_version'] ?? null) !== ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION) {
|
|
return $this->blocked($resourceType, 'unsupported_normalized_payload_shape');
|
|
}
|
|
|
|
$contentBackedBlocker = $this->contentBackedBlocker($payloadHash, $metadata);
|
|
|
|
if ($contentBackedBlocker !== null) {
|
|
return $this->blocked($resourceType, $contentBackedBlocker);
|
|
}
|
|
|
|
$identity = $this->sourceIdentity($normalizedPayload);
|
|
|
|
if ($identity === null) {
|
|
return $this->blocked($resourceType, 'missing_stable_identity');
|
|
}
|
|
|
|
if ($resourceType === 'remoteDomain' && in_array($this->canonicalFieldKey($identity['field']), self::UNSAFE_REMOTE_DOMAIN_IDENTITY_FIELDS, true)) {
|
|
return $this->blocked($resourceType, 'unsafe_identity');
|
|
}
|
|
|
|
if ($this->identityHmacKey() === null) {
|
|
return $this->blocked($resourceType, 'missing_identity_hash_key');
|
|
}
|
|
|
|
if ($this->firstUnsafeExactValuePath($resourceType, $normalizedPayload) !== null) {
|
|
return $this->blocked($resourceType, 'unsafe_exact_value');
|
|
}
|
|
|
|
$payload = $this->sortAssociative([
|
|
'workload' => 'exchange',
|
|
'resource_type' => $resourceType,
|
|
'comparable_payload_shape_version' => self::PAYLOAD_SHAPE_VERSION,
|
|
'compare_input' => 'normalized_payload_only',
|
|
'identity' => $this->comparableIdentity($resourceType, $identity),
|
|
...$this->targetPayload($resourceType, $normalizedPayload),
|
|
'redaction' => $this->redactionSummary($normalizedPayload),
|
|
'source' => $this->sourceMetadata($normalizedPayload),
|
|
'provenance' => $this->provenance($payloadHash, $metadata),
|
|
]);
|
|
|
|
return [
|
|
'comparable' => true,
|
|
'blocker' => null,
|
|
'resource_type' => $resourceType,
|
|
'payload' => $payload,
|
|
'comparable_hash' => $this->hashComparablePayload($payload),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{field: string, value: string}
|
|
*/
|
|
private function sourceIdentity(array $normalizedPayload): ?array
|
|
{
|
|
$identity = $normalizedPayload['source_identity'] ?? null;
|
|
|
|
if (! is_array($identity)) {
|
|
return null;
|
|
}
|
|
|
|
$field = $this->stringValue($identity['field'] ?? null);
|
|
$value = $this->stringValue($identity['value'] ?? null);
|
|
|
|
if ($field === null || $value === null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'field' => $field,
|
|
'value' => $value,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{field: string, value: string} $identity
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function comparableIdentity(string $resourceType, array $identity): array
|
|
{
|
|
$valueHmac = $this->identityHmac($resourceType, $identity);
|
|
|
|
return [
|
|
'field' => $identity['field'],
|
|
'value_hmac' => $valueHmac,
|
|
'source_key' => $resourceType.':'.$identity['field'].':'.$valueHmac,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $metadata
|
|
*/
|
|
private function contentBackedBlocker(?string $payloadHash, array $metadata): ?string
|
|
{
|
|
$payloadHash = $this->stringValue($payloadHash);
|
|
|
|
if ($payloadHash === null) {
|
|
return 'missing_payload_hash';
|
|
}
|
|
|
|
if (! preg_match('/^[a-f0-9]{64}$/', $payloadHash)) {
|
|
return 'invalid_payload_hash';
|
|
}
|
|
|
|
if ($this->stringValue($metadata['evidence_state'] ?? null) !== EvidenceState::ContentBacked->value) {
|
|
return 'evidence_not_content_backed';
|
|
}
|
|
|
|
if ($this->stringValue($metadata['coverage_level'] ?? null) !== CoverageLevel::ContentBacked->value) {
|
|
return 'coverage_not_content_backed';
|
|
}
|
|
|
|
if ($this->stringValue($metadata['capture_outcome'] ?? null) !== CaptureOutcome::Captured->value) {
|
|
return 'capture_not_captured';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array{field: string, value: string} $identity
|
|
*/
|
|
private function identityHmac(string $resourceType, array $identity): string
|
|
{
|
|
return hash_hmac(
|
|
'sha256',
|
|
$resourceType."\0".$identity['field']."\0".$identity['value'],
|
|
(string) $this->identityHmacKey(),
|
|
);
|
|
}
|
|
|
|
private function identityHmacKey(): ?string
|
|
{
|
|
$key = $this->stringValue(config('app.key'));
|
|
|
|
if ($key === null) {
|
|
return null;
|
|
}
|
|
|
|
if (str_starts_with($key, 'base64:')) {
|
|
$decoded = base64_decode(substr($key, 7), true);
|
|
|
|
return $decoded !== false && $decoded !== '' ? $decoded : null;
|
|
}
|
|
|
|
return $key;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $normalizedPayload
|
|
*/
|
|
private function firstUnsafeExactValuePath(string $resourceType, array $normalizedPayload): ?string
|
|
{
|
|
foreach (self::SAFE_EXACT_VALUE_POLICIES[$resourceType] as $section => $safeFields) {
|
|
$sectionValue = $this->arrayValue($normalizedPayload[$section] ?? null);
|
|
|
|
if (in_array($section, self::PARTIAL_EXACT_VALUE_SECTIONS[$resourceType] ?? [], true)) {
|
|
foreach ($safeFields as $field => $policy) {
|
|
$value = $this->sectionValueByCanonicalField($sectionValue, (string) $field);
|
|
|
|
if ($value === null || $this->isRedactedMarker($value)) {
|
|
continue;
|
|
}
|
|
|
|
if (! $this->safeExactValue($policy, $value)) {
|
|
return $section.'.'.(string) $field;
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
foreach ($sectionValue as $field => $value) {
|
|
if ($value === null || $this->isRedactedMarker($value)) {
|
|
continue;
|
|
}
|
|
|
|
$policy = $this->exactValuePolicy($safeFields, (string) $field);
|
|
|
|
if ($policy === null || ! $this->safeExactValue($policy, $value)) {
|
|
return $section.'.'.(string) $field;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $normalizedPayload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function targetPayload(string $resourceType, array $normalizedPayload): array
|
|
{
|
|
return match ($resourceType) {
|
|
'transportRule' => $this->transportRulePayload($normalizedPayload),
|
|
'remoteDomain' => $this->remoteDomainPayload($normalizedPayload),
|
|
'inboundConnector' => $this->inboundConnectorPayload($normalizedPayload),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $normalizedPayload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function transportRulePayload(array $normalizedPayload): array
|
|
{
|
|
$material = $this->arrayValue($normalizedPayload['material'] ?? null);
|
|
|
|
return [
|
|
'material' => $this->sortAssociative([
|
|
'enabled' => $this->sanitizeComparableValue($material['Enabled'] ?? $material['enabled'] ?? null),
|
|
'mode' => $this->sanitizeComparableValue($material['Mode'] ?? $material['mode'] ?? null),
|
|
'state' => $this->sanitizeComparableValue($material['State'] ?? $material['state'] ?? null),
|
|
'priority' => $this->sanitizeComparableValue(data_get($normalizedPayload, 'rule_order.priority')),
|
|
]),
|
|
'conditions' => $this->sanitizeComparableMap($normalizedPayload['conditions'] ?? []),
|
|
'actions' => $this->sanitizeComparableMap($normalizedPayload['actions'] ?? []),
|
|
'exceptions' => $this->sanitizeComparableMap($normalizedPayload['exceptions'] ?? []),
|
|
'protected_values' => $this->sanitizeComparableMap($normalizedPayload['protected_configuration'] ?? []),
|
|
'sensitive_values' => $this->sanitizeComparableMap($normalizedPayload['sensitive_content'] ?? []),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $normalizedPayload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function remoteDomainPayload(array $normalizedPayload): array
|
|
{
|
|
return [
|
|
'remote_domain_class' => $this->sanitizeComparableMap($normalizedPayload['remote_domain_class'] ?? []),
|
|
'settings' => $this->sanitizeComparableMap($normalizedPayload['settings'] ?? []),
|
|
'protected_values' => $this->sanitizeComparableMap($normalizedPayload['protected_configuration'] ?? []),
|
|
'sensitive_values' => $this->sanitizeComparableMap($normalizedPayload['sensitive_content'] ?? []),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $normalizedPayload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function inboundConnectorPayload(array $normalizedPayload): array
|
|
{
|
|
$material = $this->arrayValue($normalizedPayload['material'] ?? null);
|
|
|
|
return [
|
|
'material' => $this->sortAssociative([
|
|
'connector_type' => $this->sanitizeComparableValue($material['ConnectorType'] ?? $material['connector_type'] ?? null),
|
|
'enabled' => $this->sanitizeComparableValue($material['Enabled'] ?? $material['enabled'] ?? null),
|
|
'require_tls' => $this->sanitizeComparableValue($material['RequireTls'] ?? $material['require_tls'] ?? null),
|
|
]),
|
|
'routing' => $this->sanitizeComparableMap($normalizedPayload['routing'] ?? []),
|
|
'protected_values' => $this->sanitizeComparableMap($normalizedPayload['protected_configuration'] ?? []),
|
|
'sensitive_values' => $this->sanitizeComparableMap($normalizedPayload['sensitive_content'] ?? []),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $normalizedPayload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function redactionSummary(array $normalizedPayload): array
|
|
{
|
|
return $this->sortAssociative([
|
|
'protected_fields' => $this->stringList(data_get($normalizedPayload, 'diagnostics.protected_fields', [])),
|
|
'sensitive_fields' => $this->stringList(data_get($normalizedPayload, 'diagnostics.sensitive_fields', [])),
|
|
'volatile_fields_ignored' => $this->stringList(data_get($normalizedPayload, 'diagnostics.volatile_fields', [])),
|
|
'protected_values_use_safe_markers' => true,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $normalizedPayload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sourceMetadata(array $normalizedPayload): array
|
|
{
|
|
$source = $this->arrayValue($normalizedPayload['source'] ?? null);
|
|
|
|
return $this->sortAssociative([
|
|
'source_surface' => $this->stringValue($source['source_surface'] ?? $normalizedPayload['source_surface'] ?? null),
|
|
'source_contract_key' => $this->stringValue($source['source_contract_key'] ?? null),
|
|
'command_contract_name' => $this->stringValue($source['command_contract_name'] ?? null),
|
|
'command_contract_version' => $this->stringValue($source['command_contract_version'] ?? null),
|
|
'payload_shape_version' => $this->stringValue($source['payload_shape_version'] ?? $normalizedPayload['payload_shape_version'] ?? null),
|
|
'normalizer_version' => $this->stringValue($source['normalizer_version'] ?? $normalizedPayload['normalizer_version'] ?? null),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $metadata
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function provenance(?string $payloadHash, array $metadata): array
|
|
{
|
|
return $this->sortAssociative([
|
|
'payload_hash' => $this->stringValue($payloadHash),
|
|
'source_contract_key' => $this->stringValue($metadata['source_contract_key'] ?? null),
|
|
'source_endpoint' => $this->stringValue($metadata['source_endpoint'] ?? null),
|
|
'source_version' => $this->stringValue($metadata['source_version'] ?? null),
|
|
'source_schema_hash' => $this->stringValue($metadata['source_schema_hash'] ?? null),
|
|
'evidence_state' => $this->stringValue($metadata['evidence_state'] ?? null),
|
|
'coverage_level' => $this->stringValue($metadata['coverage_level'] ?? null),
|
|
'capture_outcome' => $this->stringValue($metadata['capture_outcome'] ?? null),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function hashComparablePayload(array $payload): string
|
|
{
|
|
unset($payload['provenance']);
|
|
|
|
return hash('sha256', json_encode($this->sortAssociative($payload), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
private function sanitizeComparableValue(mixed $value): mixed
|
|
{
|
|
if ($this->isRedactedMarker($value)) {
|
|
$present = (bool) ($value['present'] ?? false);
|
|
$count = is_numeric($value['count'] ?? null) ? (int) $value['count'] : ($present ? 1 : 0);
|
|
|
|
return [
|
|
'redacted' => true,
|
|
'present' => $present,
|
|
'count' => max(0, $count),
|
|
'state' => $present ? ($count > 1 ? 'present_multiple' : 'present') : 'absent',
|
|
];
|
|
}
|
|
|
|
if (is_bool($value) || is_int($value) || is_float($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (is_string($value)) {
|
|
$value = trim($value);
|
|
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
|
|
if (! is_array($value)) {
|
|
return null;
|
|
}
|
|
|
|
if (array_is_list($value)) {
|
|
$items = array_values(array_filter(
|
|
array_map(fn (mixed $item): mixed => $this->sanitizeComparableValue($item), $value),
|
|
static fn (mixed $item): bool => $item !== null && $item !== '',
|
|
));
|
|
|
|
usort($items, static fn (mixed $left, mixed $right): int => strcmp(
|
|
json_encode($left, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
|
|
json_encode($right, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
|
|
));
|
|
|
|
return $items;
|
|
}
|
|
|
|
return $this->sanitizeComparableMap($value);
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sanitizeComparableMap(mixed $value): array
|
|
{
|
|
$value = $this->arrayValue($value);
|
|
$sanitized = [];
|
|
|
|
foreach ($value as $key => $item) {
|
|
$sanitized[(string) $key] = $this->sanitizeComparableValue($item);
|
|
}
|
|
|
|
return $this->sortAssociative($sanitized);
|
|
}
|
|
|
|
private function isRedactedMarker(mixed $value): bool
|
|
{
|
|
if (! is_array($value) || array_is_list($value)) {
|
|
return false;
|
|
}
|
|
|
|
return ($value['value'] ?? null) === '[redacted]' || ($value['redacted'] ?? null) === true;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sortAssociative(array $payload): array
|
|
{
|
|
$payload = array_filter($payload, static fn (mixed $value): bool => $value !== null);
|
|
|
|
ksort($payload, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
foreach ($payload as $key => $value) {
|
|
if (is_array($value) && ! array_is_list($value)) {
|
|
$payload[$key] = $this->sortAssociative($value);
|
|
}
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function arrayValue(mixed $value): array
|
|
{
|
|
if (! is_array($value) || array_is_list($value)) {
|
|
return [];
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function stringValue(mixed $value): ?string
|
|
{
|
|
if ($value instanceof BackedEnum) {
|
|
$value = $value->value;
|
|
}
|
|
|
|
if (! is_scalar($value)) {
|
|
return null;
|
|
}
|
|
|
|
if (is_bool($value)) {
|
|
return $value ? 'true' : 'false';
|
|
}
|
|
|
|
$value = trim((string) $value);
|
|
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function sectionValueByCanonicalField(array $payload, string $field): mixed
|
|
{
|
|
$field = $this->canonicalFieldKey($field);
|
|
|
|
foreach ($payload as $candidateField => $value) {
|
|
if ($this->canonicalFieldKey((string) $candidateField) === $field) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string|array{enum: list<string>}> $policies
|
|
* @return string|array{enum: list<string>}|null
|
|
*/
|
|
private function exactValuePolicy(array $policies, string $field): string|array|null
|
|
{
|
|
$field = $this->canonicalFieldKey($field);
|
|
|
|
foreach ($policies as $policyField => $policy) {
|
|
if ($this->canonicalFieldKey((string) $policyField) === $field) {
|
|
return $policy;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param string|array{enum: list<string>} $policy
|
|
*/
|
|
private function safeExactValue(string|array $policy, mixed $value): bool
|
|
{
|
|
if ($policy === 'bool') {
|
|
return is_bool($value);
|
|
}
|
|
|
|
if ($policy === 'non_negative_integer') {
|
|
if (is_int($value)) {
|
|
return $value >= 0;
|
|
}
|
|
|
|
if (! is_string($value)) {
|
|
return false;
|
|
}
|
|
|
|
$value = trim($value);
|
|
|
|
return $value !== '' && ctype_digit($value);
|
|
}
|
|
|
|
if (is_array($policy) && array_key_exists('enum', $policy)) {
|
|
$value = $this->stringValue($value);
|
|
|
|
if ($value === null) {
|
|
return false;
|
|
}
|
|
|
|
$allowed = array_map(fn (string $item): string => $this->canonicalFieldKey($item), $policy['enum']);
|
|
|
|
return in_array($this->canonicalFieldKey($value), $allowed, true);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function canonicalFieldKey(string $field): string
|
|
{
|
|
return strtolower(preg_replace('/[^A-Za-z0-9]+/', '', $field) ?? '');
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function stringList(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$values = array_values(array_filter(
|
|
array_map(static fn (mixed $item): string => is_string($item) ? trim($item) : '', $value),
|
|
static fn (string $item): bool => $item !== '',
|
|
));
|
|
|
|
sort($values, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $values;
|
|
}
|
|
|
|
/**
|
|
* @return array{comparable: false, blocker: string, resource_type: string, payload: null}
|
|
*/
|
|
private function blocked(string $resourceType, string $blocker): array
|
|
{
|
|
return [
|
|
'comparable' => false,
|
|
'blocker' => $blocker,
|
|
'resource_type' => $resourceType,
|
|
'payload' => null,
|
|
];
|
|
}
|
|
}
|