From 00e6048a63acd2673845ee69e7e233fcc891afa8 Mon Sep 17 00:00:00 2001 From: markford Date: Fri, 24 Jun 2022 05:38:39 -0400 Subject: [PATCH 1/2] fix: detect invalid outbound transition from map/parallel state. Resolves issue #78 --- .gitignore | 1 + .../definitions/invalid-map-ob-link.json | 44 ++++++++++++ .../definitions/invalid-parallel-ob-link.json | 56 +++++++++++++++ src/lib/state-transitions.js | 71 +++++++++++++++++++ src/validator.js | 12 +++- 5 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/definitions/invalid-map-ob-link.json create mode 100644 src/__tests__/definitions/invalid-parallel-ob-link.json create mode 100644 src/lib/state-transitions.js diff --git a/.gitignore b/.gitignore index dffd8b3..6a71e72 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules coverage reports **/junit.xml +.idea diff --git a/src/__tests__/definitions/invalid-map-ob-link.json b/src/__tests__/definitions/invalid-map-ob-link.json new file mode 100644 index 0000000..39c50fc --- /dev/null +++ b/src/__tests__/definitions/invalid-map-ob-link.json @@ -0,0 +1,44 @@ +{ + "Comment": "An example of the Amazon States Language using a map state to map over items in parallel.", + "StartAt": "Map", + "States": { + "Map": { + "Type": "Map", + "Next": "Final State", + "InputPath": "$.input", + "ItemsPath": "$.items", + "MaxConcurrency": 0, + "Iterator": { + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.foo", + "NumericEquals": 1, + "Next": "Wait 20s" + }, + { + "Variable": "$.foo", + "NumericEquals": 2, + "Next": "Final State" + } + ], + "Default": "Wait 20s" + }, + "Wait 20s": { + "Type": "Wait", + "Seconds": 20, + "End": true + } + } + }, + "ResultPath": "$.result" + }, + "Final State": { + "Type": "Pass", + "End": true + } + } +} diff --git a/src/__tests__/definitions/invalid-parallel-ob-link.json b/src/__tests__/definitions/invalid-parallel-ob-link.json new file mode 100644 index 0000000..038d8ff --- /dev/null +++ b/src/__tests__/definitions/invalid-parallel-ob-link.json @@ -0,0 +1,56 @@ +{ + "Comment": "An example of the Amazon States Language using a parallel state to execute two branches at the same time.", + "StartAt": "Parallel", + "States": { + "Parallel": { + "Type": "Parallel", + "Next": "Final State", + "Branches": [ + { + "StartAt": "Wait 20s", + "States": { + "Wait 20s": { + "Type": "Wait", + "Seconds": 20, + "End": true + } + } + }, + { + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Next": "ChoiceState" + }, + "ChoiceState": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.foo", + "NumericEquals": 1, + "Next": "Wait 10s" + }, + { + "Variable": "$.foo", + "NumericEquals": 2, + "Next": "Final State" + } + ], + "Default": "Wait 10s" + }, + "Wait 10s": { + "Type": "Wait", + "Seconds": 10, + "End": true + } + } + } + ] + }, + "Final State": { + "Type": "Pass", + "End": true + } + } +} diff --git a/src/lib/state-transitions.js b/src/lib/state-transitions.js new file mode 100644 index 0000000..2cf29a5 --- /dev/null +++ b/src/lib/state-transitions.js @@ -0,0 +1,71 @@ +const jp = require('jsonpath'); + +module.exports = (definition) => { + const errorMessages = []; + + // given a nested state machine, this function will examine + // each state and record its `Next` or `Default` values + // to see what states are reachable. + // Avoids traversing into Map or Parallel states since the + // states defined within those containers are not valid + // targets for states outside the containers. + const nextAndDefaultTargets = (nestedStateMachine) => { + const states = []; + Object.keys(nestedStateMachine.States).forEach((stateName) => { + const nestedState = nestedStateMachine.States[stateName]; + const isContainer = ['Map', 'Parallel'].indexOf(nestedState.Type) >= 0; + const path = isContainer ? '$.[\'Next\',\'Default\']' : '$..[\'Next\',\'Default\']'; + states.push(...jp.query(nestedState, path)); + }); + return states; + }; + + // reports an error for each state that is found to be an invalid + // transition + const validateNestedStateMachine = (nestedStateMachine) => { + let availStateNames = []; + // don't traverse into any nested states. We only want to record the States + // that are immediately under the Branch. + // These are the only valid states to link to from within the branch + jp.query(nestedStateMachine, '$.States').forEach((branchStates) => { + availStateNames = availStateNames.concat(Object.keys(branchStates)); + }); + + // check that there are no transitions outside this branch + const targetedStates = nextAndDefaultTargets(nestedStateMachine); + + return targetedStates.filter((state) => availStateNames.indexOf(state) === -1); + }; + + // we know the step function is schema valid + // we know that every `Parallel` state has its expected `Branches` field + // we need to visit each Branch within a Parallel to ensure that it doesn't + // link outside its branch. + jp.query(definition, '$..[\'Branches\']') + .forEach((parallelBranches) => { + parallelBranches.forEach((nestedStateMachine) => { + const errs = validateNestedStateMachine(nestedStateMachine).map((state) => ({ + 'Error code': 'BRANCH_OUTBOUND_TRANSITION_TARGET', + Message: `Parallel branch state cannot transition to target: ${state}`, + })); + + errorMessages.push(...errs); + }); + }); + + // we know the step function is schema valid + // we know that every `Map` state has its expected `Iterator` field + // we need to visit the Iterator within a Map to ensure that it doesn't + // link outside its container. + jp.query(definition, '$..[\'Iterator\']') + .forEach((nestedStateMachine) => { + const errs = validateNestedStateMachine(nestedStateMachine).map((state) => ({ + 'Error code': 'MAP_OUTBOUND_TRANSITION_TARGET', + Message: `Map branch state cannot transition to target: ${state}`, + })); + + errorMessages.push(...errs); + }); + + return errorMessages; +}; diff --git a/src/validator.js b/src/validator.js index 680518c..b1d6c3d 100644 --- a/src/validator.js +++ b/src/validator.js @@ -13,6 +13,7 @@ const map = require('./schemas/map'); const errors = require('./schemas/errors'); const checkJsonPath = require('./lib/json-path-errors'); const missingTransitionTarget = require('./lib/missing-transition-target'); +const stateTransitions = require('./lib/state-transitions'); function formatError(e) { const code = e.Code ? e.Code : e['Error code']; @@ -45,9 +46,15 @@ function validator(definition) { // Validating JSON schemas const isJsonSchemaValid = ajv.validate('http://asl-validator.cloud/state-machine.json#', definition); + // Check for Parallel states + const transitionErrors = isJsonSchemaValid ? stateTransitions(definition) : []; + return { - isValid: isJsonSchemaValid && !jsonPathErrors.length && !missingTransitionTargetErrors.length, - errors: jsonPathErrors.concat(ajv.errors || []).concat(missingTransitionTargetErrors || []), + isValid: isJsonSchemaValid && !jsonPathErrors.length && !missingTransitionTargetErrors.length + && !transitionErrors.length, + errors: jsonPathErrors.concat(ajv.errors || []) + .concat(missingTransitionTargetErrors || []) + .concat(transitionErrors || []), errorsText: (separator = '\n') => { const errorList = []; errorList.push(jsonPathErrors.map(formatError).join(separator)); @@ -55,6 +62,7 @@ function validator(definition) { errorList.push(ajv.errorsText(ajv.errors, { separator })); } errorList.push(missingTransitionTargetErrors.map(formatError).join(separator)); + errorList.push(transitionErrors.join(separator)); return errorList.join(separator); }, }; From 993942238046ffb5f9912929e97ef4f0450726f3 Mon Sep 17 00:00:00 2001 From: markford Date: Fri, 24 Jun 2022 11:25:24 -0400 Subject: [PATCH 2/2] address PR comments: better comment in asl source and error formatting. --- src/__tests__/definitions/invalid-map-ob-link.json | 2 +- src/__tests__/definitions/invalid-parallel-ob-link.json | 2 +- src/validator.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/definitions/invalid-map-ob-link.json b/src/__tests__/definitions/invalid-map-ob-link.json index 39c50fc..0f35049 100644 --- a/src/__tests__/definitions/invalid-map-ob-link.json +++ b/src/__tests__/definitions/invalid-map-ob-link.json @@ -1,5 +1,5 @@ { - "Comment": "An example of the Amazon States Language using a map state to map over items in parallel.", + "Comment": "The link to \"Final State\" from within the Iterator is invalid since the target state is defined outside of the Iterator", "StartAt": "Map", "States": { "Map": { diff --git a/src/__tests__/definitions/invalid-parallel-ob-link.json b/src/__tests__/definitions/invalid-parallel-ob-link.json index 038d8ff..5d40645 100644 --- a/src/__tests__/definitions/invalid-parallel-ob-link.json +++ b/src/__tests__/definitions/invalid-parallel-ob-link.json @@ -1,5 +1,5 @@ { - "Comment": "An example of the Amazon States Language using a parallel state to execute two branches at the same time.", + "Comment": "The link to the \"Final State\" within the Choice is invalid because it targets a state outside of its parent branch", "StartAt": "Parallel", "States": { "Parallel": { diff --git a/src/validator.js b/src/validator.js index b1d6c3d..0f8c956 100644 --- a/src/validator.js +++ b/src/validator.js @@ -62,7 +62,7 @@ function validator(definition) { errorList.push(ajv.errorsText(ajv.errors, { separator })); } errorList.push(missingTransitionTargetErrors.map(formatError).join(separator)); - errorList.push(transitionErrors.join(separator)); + errorList.push(transitionErrors.map(formatError).join(separator)); return errorList.join(separator); }, };