fix: complete task 5 rereview repairs
This commit is contained in:
@@ -59,30 +59,97 @@ export const hasBoundedInputExpressionNodeBudget = (
|
||||
return visitExpression(input);
|
||||
};
|
||||
|
||||
/** Find and bound generated expression-shaped values anywhere in an RPC value. */
|
||||
export const hasBoundedInputExpressionPayload = (
|
||||
type JsonSchemaRecord = Readonly<Record<string, unknown>>;
|
||||
|
||||
const schemaRecord = (value: unknown): JsonSchemaRecord | null =>
|
||||
isRecord(value) ? value : null;
|
||||
|
||||
const localComponentName = (ref: unknown): string | null => {
|
||||
if (typeof ref !== "string") return null;
|
||||
const prefix = "#/components/schemas/";
|
||||
return ref.startsWith(prefix) ? ref.slice(prefix.length) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bound only expression bindings found through a generated operation schema.
|
||||
*
|
||||
* This deliberately follows schema positions instead of inspecting arbitrary
|
||||
* JSON for expression-shaped objects. Ordinary runtime data can use the same
|
||||
* `kind`/`value` keys without becoming an input expression.
|
||||
*/
|
||||
export const hasBoundedInputExpressionsAtSchema = (
|
||||
input: unknown,
|
||||
schema: unknown,
|
||||
components: Readonly<Record<string, unknown>>,
|
||||
maxNodes: number = MAX_INPUT_EXPRESSION_NODES,
|
||||
): boolean => {
|
||||
const active = new WeakSet<object>();
|
||||
const visit = (value: unknown): boolean => {
|
||||
const activeValues = new WeakSet<object>();
|
||||
const activeComponents = new Set<string>();
|
||||
|
||||
const visit = (value: unknown, currentSchema: unknown): boolean => {
|
||||
const schemaValue = schemaRecord(currentSchema);
|
||||
if (schemaValue === null) return true;
|
||||
|
||||
const componentName = localComponentName(schemaValue.$ref);
|
||||
if (componentName !== null) {
|
||||
if (componentName === "StepInputBinding" || componentName === "InputExpressionBinding") {
|
||||
return isRecord(value) && "expression" in value
|
||||
? hasBoundedInputExpressionNodeBudget(value.expression, maxNodes)
|
||||
: true;
|
||||
}
|
||||
const component = components[componentName];
|
||||
if (component === undefined || activeComponents.has(componentName)) return true;
|
||||
activeComponents.add(componentName);
|
||||
const valid = visit(value, component);
|
||||
activeComponents.delete(componentName);
|
||||
return valid;
|
||||
}
|
||||
|
||||
for (const key of ["allOf", "anyOf", "oneOf"] as const) {
|
||||
const branches = schemaValue[key];
|
||||
if (Array.isArray(branches) && !branches.every((branch) => visit(value, branch))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value !== "object" || value === null) return true;
|
||||
if (active.has(value)) return false;
|
||||
active.add(value);
|
||||
const valid = Array.isArray(value)
|
||||
? value.every(visit)
|
||||
: isRecord(value) &&
|
||||
(typeof value.kind === "string" &&
|
||||
expressionKinds.has(value.kind) &&
|
||||
((value.kind === "literal" && "value" in value) ||
|
||||
(value.kind === "path" && "path" in value) ||
|
||||
(value.kind === "array" && Array.isArray(value.items)) ||
|
||||
(value.kind === "object" && isRecord(value.fields)))
|
||||
? hasBoundedInputExpressionNodeBudget(value, maxNodes)
|
||||
: Object.values(value).every(visit));
|
||||
active.delete(value);
|
||||
return valid;
|
||||
if (activeValues.has(value)) return false;
|
||||
activeValues.add(value);
|
||||
|
||||
const properties = schemaRecord(schemaValue.properties);
|
||||
if (properties !== null && isRecord(value)) {
|
||||
for (const [key, propertySchema] of Object.entries(properties)) {
|
||||
if (key in value && !visit(value[key], propertySchema)) {
|
||||
activeValues.delete(value);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items = schemaValue.items;
|
||||
if (Array.isArray(value) && items !== undefined) {
|
||||
for (const item of value) {
|
||||
if (!visit(item, items)) {
|
||||
activeValues.delete(value);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const additionalProperties = schemaValue.additionalProperties;
|
||||
if (isRecord(value) && schemaRecord(additionalProperties) !== null) {
|
||||
const knownProperties = properties === null ? new Set<string>() : new Set(Object.keys(properties));
|
||||
for (const [key, propertyValue] of Object.entries(value)) {
|
||||
if (!knownProperties.has(key) && !visit(propertyValue, additionalProperties)) {
|
||||
activeValues.delete(value);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeValues.delete(value);
|
||||
return true;
|
||||
};
|
||||
|
||||
return visit(input);
|
||||
return visit(input, schema);
|
||||
};
|
||||
|
||||
@@ -110,6 +110,40 @@ describe("runtimeSchemasFor", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a large ordinary JSON value shaped like a literal in a simple binding", () => {
|
||||
const schemas = runtimeSchemasFor(
|
||||
"workflow.draft_workspaces.set_step_input_bindings",
|
||||
);
|
||||
const ordinaryValue = {
|
||||
kind: "literal",
|
||||
value: Array.from({ length: 1025 }, () => ({})),
|
||||
};
|
||||
|
||||
expect(
|
||||
accepts(schemas.payload, {
|
||||
workspace_id: "console.demo",
|
||||
revision: 3,
|
||||
step_id: "render",
|
||||
bindings: [{ target: "request", value: ordinaryValue }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a large ordinary JSON value shaped like a literal as workflow input", () => {
|
||||
const schemas = runtimeSchemasFor("workflow.runs.start");
|
||||
const ordinaryValue = {
|
||||
kind: "literal",
|
||||
value: Array.from({ length: 1025 }, () => ({})),
|
||||
};
|
||||
|
||||
expect(
|
||||
accepts(schemas.payload, {
|
||||
deployment_id: "report.default",
|
||||
workflow_input: { request: ordinaryValue },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("counts nested literal array and object containers in the input budget", () => {
|
||||
const schemas = runtimeSchemasFor(
|
||||
"workflow.draft_workspaces.set_step_input_bindings",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type WorkflowOperationResult,
|
||||
} from "../generated/workflow-contract.js";
|
||||
import { translateJsonSchema } from "./translator.js";
|
||||
import { hasBoundedInputExpressionPayload } from "./input-expression-limits.js";
|
||||
import { hasBoundedInputExpressionsAtSchema } from "./input-expression-limits.js";
|
||||
|
||||
type RuntimeOperationName = keyof typeof workflowRuntimeContract.operations;
|
||||
const MAX_RUNTIME_VALUE_DEPTH = 64;
|
||||
@@ -48,10 +48,6 @@ const BoundedRuntimeValueSchema = Schema.Unknown.pipe(
|
||||
message: () =>
|
||||
`runtime value exceeds ${MAX_RUNTIME_VALUE_DEPTH} nested containers`,
|
||||
}),
|
||||
Schema.filter((value) => hasBoundedInputExpressionPayload(value), {
|
||||
message: () =>
|
||||
"runtime value contains an input expression over the 1024-node budget",
|
||||
}),
|
||||
);
|
||||
|
||||
const translatedAst = (schema: unknown): AST.AST => {
|
||||
@@ -74,7 +70,20 @@ const payloadSchemaFor = <Name extends RuntimeOperationName>(
|
||||
const schema = Schema.make<WorkflowOperationParams<Name>, unknown, never>(
|
||||
translatedAst(workflowRuntimeContract.operations[name].payload),
|
||||
);
|
||||
return Schema.compose(BoundedRuntimeValueSchema, schema);
|
||||
return Schema.compose(BoundedRuntimeValueSchema, schema).pipe(
|
||||
Schema.filter(
|
||||
(value) =>
|
||||
hasBoundedInputExpressionsAtSchema(
|
||||
value,
|
||||
workflowRuntimeContract.operations[name].payload,
|
||||
workflowRuntimeContract.components,
|
||||
),
|
||||
{
|
||||
message: () =>
|
||||
"runtime value contains an input expression over the 1024-node budget",
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const successSchemaFor = <Name extends RuntimeOperationName>(
|
||||
@@ -84,7 +93,20 @@ const successSchemaFor = <Name extends RuntimeOperationName>(
|
||||
const schema = Schema.make<WorkflowOperationResult<Name>, unknown, never>(
|
||||
translatedAst(workflowRuntimeContract.operations[name].success),
|
||||
);
|
||||
return Schema.compose(BoundedRuntimeValueSchema, schema);
|
||||
return Schema.compose(BoundedRuntimeValueSchema, schema).pipe(
|
||||
Schema.filter(
|
||||
(value) =>
|
||||
hasBoundedInputExpressionsAtSchema(
|
||||
value,
|
||||
workflowRuntimeContract.operations[name].success,
|
||||
workflowRuntimeContract.components,
|
||||
),
|
||||
{
|
||||
message: () =>
|
||||
"runtime value contains an input expression over the 1024-node budget",
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** Returns fail-fast Effect schemas for one parity-verified authored RPC. */
|
||||
|
||||
Reference in New Issue
Block a user