diff --git a/Taskfile.yml b/Taskfile.yml index 1a9f32f..620737c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -73,6 +73,8 @@ tasks: ignore_error: false - cmd: ./{{.BINARY}} check --branch feat/x testdata/workflow_vars.yml ignore_error: false + - cmd: ./{{.BINARY}} check testdata/workflow_escape.yml + ignore_error: false - cmd: ./{{.BINARY}} check testdata/variable_refs.yml ignore_error: false - cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml diff --git a/internal/model/parser.go b/internal/model/parser.go index 8ea3f7b..10ca1cb 100644 --- a/internal/model/parser.go +++ b/internal/model/parser.go @@ -24,6 +24,8 @@ func Parse(path string) (*Pipeline, error) { // ParseBytes parses YAML from an in-memory byte slice. func ParseBytes(data []byte) (*Pipeline, error) { + data = sanitizeYAMLEscapes(data) + // First pass: parse into a yaml.Node document to extract job keys with // their exact source line numbers (key nodes carry the line, value nodes // carry the body we decode into Job / map[string]any). @@ -75,3 +77,63 @@ func ParseBytes(data []byte) (*Pipeline, error) { return p, nil } + +// sanitizeYAMLEscapes rewrites double-quoted YAML strings, replacing the \/ +// escape sequence (unrecognised by gopkg.in/yaml.v3) with \\/ so that the +// parser produces a literal backslash+slash — preserving regex patterns like +// /^us\// that appear in GitLab CI if: expressions. +func sanitizeYAMLEscapes(data []byte) []byte { + type state int + const ( + stOutside state = iota + stSingleQ + stDoubleQ + stEscape + ) + + out := make([]byte, 0, len(data)) + s := stOutside + + for i := 0; i < len(data); i++ { + b := data[i] + switch s { + case stOutside: + out = append(out, b) + switch b { + case '"': + s = stDoubleQ + case '\'': + s = stSingleQ + } + case stSingleQ: + out = append(out, b) + if b == '\'' { + if i+1 < len(data) && data[i+1] == '\'' { + // '' inside a single-quoted string is an escaped single-quote + out = append(out, data[i+1]) + i++ + } else { + s = stOutside + } + } + case stDoubleQ: + out = append(out, b) + switch b { + case '\\': + s = stEscape + case '"': + s = stOutside + } + case stEscape: + if b == '/' { + // \/ is not recognised by yaml.v3; rewrite as \\/ which + // the parser resolves to a literal backslash + slash. + out = append(out, '\\', '/') + } else { + out = append(out, b) + } + s = stDoubleQ + } + } + return out +} diff --git a/testdata/workflow_escape.yml b/testdata/workflow_escape.yml new file mode 100644 index 0000000..bd9ea31 --- /dev/null +++ b/testdata/workflow_escape.yml @@ -0,0 +1,57 @@ +--- +# workflow_vars.yml +# Exercises workflow:rules:variables: injection. +# The matching workflow rule sets DEPLOY_TARGET; job rules use it. +# +# Expected behaviour per context: +# (no context) → all jobs active (no context evaluation) +# --branch main → deploy-prod active, deploy-staging skipped +# --branch develop → deploy-prod skipped, deploy-staging active +# --branch feat/x → deploy-prod skipped, deploy-staging skipped (manual) + +stages: + - build + - deploy + +variables: + DEPLOY_TARGET: + value: "" + description: "Deployment target — set by workflow rules" + +workflow: + rules: + - if: " + $WORKFLOW = 'gitflow' && + $CI_PIPELINE_SOURCE == /(push|web)/ && + $CI_COMMIT_BRANCH =~ /^us\// + " + variables: + DEPLOY_TARGET: production + BUILD: true + TEST: true + - if: '$CI_COMMIT_BRANCH == "develop"' + variables: + DEPLOY_TARGET: staging + - when: always + +build: + stage: build + script: make build + rules: + - when: always + +deploy-prod: + stage: deploy + script: make deploy ENV=production + rules: + - if: '$DEPLOY_TARGET == "production"' + when: on_success + - when: never + +deploy-staging: + stage: deploy + script: make deploy ENV=staging + rules: + - if: '$DEPLOY_TARGET == "staging"' + when: on_success + - when: never