Harden contract helper validators
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
||||
DatasetSyncState,
|
||||
InventoryCursorLookup,
|
||||
InventoryCursorState,
|
||||
InventoryQueryResult,
|
||||
MissingRequirement,
|
||||
Requirement,
|
||||
ResourceAuthorization,
|
||||
@@ -83,11 +84,14 @@ const SYSTEM_JOB_DATASETS: Readonly<Record<'reference' | 'public_feed' | 'mainte
|
||||
|
||||
export type InventoryCursorValidation =
|
||||
| { valid: true }
|
||||
| { valid: false; reason: 'token_mismatch' | 'expired' | 'exhausted' | 'caller_mismatch' | 'query_mismatch' | 'resource_mismatch' | 'authorization_mismatch' | 'input_version_mismatch' };
|
||||
| { valid: false; reason: 'token_mismatch' | 'expired' | 'exhausted' | 'caller_mismatch' | 'query_mismatch' | 'resource_mismatch' | 'authorization_mismatch' | 'input_version_mismatch' | 'malformed_state' };
|
||||
export type InventoryPageValidation =
|
||||
| { valid: true }
|
||||
| { valid: false; reason: 'invalid_page_state' | 'terminal_cursor' | 'empty_page_summary' };
|
||||
|
||||
export type CompleteReportPinValidation =
|
||||
| { valid: true }
|
||||
| { valid: false; reason: 'missing_pin' | 'unexpected_pin' | 'duplicate_pin' | 'principal_mismatch' | 'revision_mismatch' | 'incomplete_coverage' };
|
||||
| { valid: false; reason: 'missing_pin' | 'unexpected_pin' | 'duplicate_pin' | 'duplicate_resource' | 'principal_mismatch' | 'revision_mismatch' | 'incomplete_coverage' };
|
||||
|
||||
export type DatasetJobFenceValidation =
|
||||
| { valid: true }
|
||||
@@ -107,6 +111,7 @@ export type CapabilityScopeValidation = {
|
||||
preservedScopes: readonly string[];
|
||||
missingScopes: readonly string[];
|
||||
unexpectedScopes: readonly string[];
|
||||
duplicateScopes: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -128,18 +133,20 @@ function validateScopeLists(
|
||||
approvedExistingScopes: readonly string[],
|
||||
): CapabilityScopeValidation {
|
||||
const serverAllowlist = new Set(SERVER_SCOPE_ALLOWLIST);
|
||||
const duplicateScopes = [...new Set([...findDuplicates(requestedScopes), ...findDuplicates(approvedExistingScopes)])];
|
||||
const preservedScopes = approvedExistingScopes.filter((scope) => serverAllowlist.has(scope));
|
||||
const allowlistedScopes = [...new Set([...requiredScopes, ...preservedScopes])];
|
||||
const allowlisted = new Set(allowlistedScopes);
|
||||
const missingScopes = requiredScopes.filter((scope) => !requestedScopes.includes(scope));
|
||||
const unexpectedScopes = requestedScopes.filter((scope) => !allowlisted.has(scope) || !serverAllowlist.has(scope));
|
||||
return {
|
||||
valid: missingScopes.length === 0 && unexpectedScopes.length === 0,
|
||||
valid: missingScopes.length === 0 && unexpectedScopes.length === 0 && duplicateScopes.length === 0,
|
||||
requiredScopes: [...requiredScopes],
|
||||
allowlistedScopes,
|
||||
preservedScopes: preservedScopes.filter((scope) => requestedScopes.includes(scope)),
|
||||
missingScopes,
|
||||
unexpectedScopes,
|
||||
duplicateScopes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,6 +167,7 @@ export function validateConsentCallback(binding: ConsentBinding, callback: Conse
|
||||
if (binding.state !== 'pending' || !Number.isFinite(now) || !Number.isFinite(binding.createdAt) || !Number.isFinite(binding.expiresAt)
|
||||
|| binding.expiresAt <= binding.createdAt || now < binding.createdAt || now >= binding.expiresAt) return { valid: false, reason: 'binding_mismatch' };
|
||||
if (callback.resourceGrants.length !== binding.resourceRequests.length) return { valid: false, reason: 'resource_mismatch' };
|
||||
if (hasDuplicatePrincipals(binding.resourceRequests.map((request) => request.resource.principal))) return { valid: false, reason: 'duplicate_resource' };
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const grant of callback.resourceGrants) {
|
||||
@@ -201,20 +209,46 @@ function samePrincipal(left: ResourcePrincipal, right: ResourcePrincipal): boole
|
||||
}
|
||||
|
||||
function principalFingerprint(principal: ResourcePrincipal): string {
|
||||
return JSON.stringify(principal);
|
||||
switch (principal.kind) {
|
||||
case 'user': return `user\u0000${principal.id}`;
|
||||
case 'character': return `character\u0000${principal.id}\u0000${principal.ownerUserId}`;
|
||||
case 'corporation': return `corporation\u0000${principal.id}\u0000${principal.ownerUserId}`;
|
||||
case 'system': return `system\u0000${principal.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
function findDuplicates(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) duplicates.add(value);
|
||||
seen.add(value);
|
||||
}
|
||||
return [...duplicates];
|
||||
}
|
||||
|
||||
function hasDuplicatePrincipals(principals: readonly ResourcePrincipal[]): boolean {
|
||||
return findDuplicates(principals.map(principalFingerprint)).length > 0;
|
||||
}
|
||||
|
||||
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((value) => right.includes(value));
|
||||
if (left.length !== right.length) return false;
|
||||
const sortedLeft = [...left].sort();
|
||||
const sortedRight = [...right].sort();
|
||||
return sortedLeft.every((value, index) => value === sortedRight[index]);
|
||||
}
|
||||
|
||||
/** Validate every server-resolved value before consuming an opaque cursor. */
|
||||
export function validateInventoryCursorUse(state: InventoryCursorState, lookup: InventoryCursorLookup): InventoryCursorValidation {
|
||||
if (state.token !== lookup.token) return { valid: false, reason: 'token_mismatch' };
|
||||
if (typeof state.token !== 'string' || state.token.length === 0 || state.token !== lookup.token) return { valid: false, reason: 'token_mismatch' };
|
||||
if (state.status !== 'active' && state.status !== 'exhausted') return { valid: false, reason: 'malformed_state' };
|
||||
if (!validCursorPosition(state.position)) return { valid: false, reason: 'malformed_state' };
|
||||
if (state.status === 'exhausted') return { valid: false, reason: 'exhausted' };
|
||||
if (!Number.isFinite(lookup.now) || !Number.isFinite(state.pin.expiresAt) || lookup.now >= state.pin.expiresAt) return { valid: false, reason: 'expired' };
|
||||
if (state.pin.callerUserId !== lookup.callerUserId) return { valid: false, reason: 'caller_mismatch' };
|
||||
if (state.pin.queryFingerprint !== lookup.queryFingerprint) return { valid: false, reason: 'query_mismatch' };
|
||||
if (hasDuplicatePrincipals(state.pin.selectedResources.map((resource) => resource.principal))
|
||||
|| hasDuplicatePrincipals(lookup.selectedResources.map((resource) => resource.principal))) return { valid: false, reason: 'resource_mismatch' };
|
||||
if (!sameSelectedResourceList(state.pin.selectedResources, lookup.selectedResources)) return { valid: false, reason: 'resource_mismatch' };
|
||||
if (!sameRecord(state.pin.authorizationRevisions, lookup.authorizationRevisions)
|
||||
|| !sameRecord(state.pin.authorizationEpochs, lookup.authorizationEpochs)) return { valid: false, reason: 'authorization_mismatch' };
|
||||
@@ -223,6 +257,30 @@ export function validateInventoryCursorUse(state: InventoryCursorState, lookup:
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/** Enforce the page discriminants without implementing page retrieval. */
|
||||
export function validateInventoryPageResult(result: InventoryQueryResult): InventoryPageValidation {
|
||||
if (result.status !== 'ready') return { valid: true };
|
||||
if (result.pageState === 'after_end') {
|
||||
return result.rows.length === 0 && result.nextCursor === null
|
||||
? { valid: true }
|
||||
: { valid: false, reason: 'terminal_cursor' };
|
||||
}
|
||||
if (result.pageState === 'initial' && result.rows.length === 0
|
||||
&& (result.summary.matchingRows !== 0 || result.summary.matchingQuantity !== '0' || (result.summary.estimatedValue !== null && result.summary.estimatedValue !== '0'))) {
|
||||
return { valid: false, reason: 'empty_page_summary' };
|
||||
}
|
||||
if (result.pageState === 'continuation' && result.rows.length === 0) return { valid: false, reason: 'invalid_page_state' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function validCursorPosition(position: unknown): boolean {
|
||||
if (!position || typeof position !== 'object') return false;
|
||||
const candidate = position as { offset?: unknown; lastItemId?: unknown };
|
||||
if (!Number.isSafeInteger(candidate.offset) || (candidate.offset as number) < 0) return false;
|
||||
if (candidate.offset === 0) return candidate.lastItemId === null;
|
||||
return typeof candidate.lastItemId === 'string' && /^(0|[1-9][0-9]*)$/.test(candidate.lastItemId);
|
||||
}
|
||||
|
||||
function sameSelectedResourceList(left: readonly SelectedResource[], right: readonly SelectedResource[]): boolean {
|
||||
return left.length === right.length && left.every((resource) => right.some((candidate) => sameSelectedResource([resource], candidate)));
|
||||
}
|
||||
@@ -236,6 +294,8 @@ function sameRecord<T extends string | number | null>(left: Readonly<Record<stri
|
||||
/** Validate the exact selected-resource × required-dataset pin set. */
|
||||
export function validateCompleteReportPins(input: CompleteReportInputPin): CompleteReportPinValidation {
|
||||
if (input.pinSet !== 'exact_selected_resource_dataset_set') return { valid: false, reason: 'unexpected_pin' };
|
||||
if (hasDuplicatePrincipals(input.selectedResources.map((resource) => resource.principal))
|
||||
|| findDuplicates(input.requiredDatasetKinds).length > 0) return { valid: false, reason: 'duplicate_resource' };
|
||||
const expected = new Set(input.selectedResources.flatMap((resource) => input.requiredDatasetKinds.map((dataset) => `${principalFingerprint(resource.principal)}|${dataset}`)));
|
||||
const seen = new Set<string>();
|
||||
for (const pin of input.datasetPins) {
|
||||
|
||||
Reference in New Issue
Block a user