Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 90 additions & 14 deletions src/specify_cli/workflows/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,31 @@ def _is_single_expression(stripped: str) -> bool:
return True


def _find_block_close(text: str, start: int) -> int:
"""Index of the ``}}`` closing the block opened by the ``{{`` at *start*, or -1.

Quote-aware, so a literal ``}}`` inside a string argument
(``{{ inputs.text | default('}}') }}``) does not close the block early --
the same rule ``_is_single_expression`` applies. Shared with
``condition_is_never_evaluated`` so the validator cannot disagree with the
substitution it is predicting.
"""
quote: str | None = None
i = start + 2
n = len(text)
while i < n:
ch = text[i]
if quote is not None:
if ch == quote:
quote = None
elif ch in ("'", '"'):
quote = ch
elif ch == "}" and i + 1 < n and text[i + 1] == "}":
return i
i += 1
return -1


def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
"""Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware.

Expand All @@ -249,20 +274,7 @@ def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
break
out.append(template[i:start])
# Scan for the block-closing ``}}`` that is outside any string literal.
j = start + 2
quote: str | None = None
close = -1
while j < n:
ch = template[j]
if quote is not None:
if ch == quote:
quote = None
elif ch in ("'", '"'):
quote = ch
elif ch == "}" and j + 1 < n and template[j + 1] == "}":
close = j
break
j += 1
close = _find_block_close(template, start)
if close == -1:
# No quote-aware close. Two sub-cases, both kept identical to the old
# regex so a malformed template is never silently hidden:
Expand Down Expand Up @@ -690,3 +702,67 @@ def evaluate_condition(condition: str, context: Any) -> bool:
if lower == "true":
return True
return bool(result)


def condition_is_never_evaluated(condition: Any) -> bool:
"""True when a string *condition* is silently treated as always-true text.

``evaluate_condition`` resolves its argument through
``evaluate_expression``, which only substitutes ``{{ ... }}`` blocks. A
string with no such block comes back unchanged, and — unless it reads
``true``/``false`` — is then coerced by ``bool()``. So an expression
authored without the braces, e.g. ``condition: inputs.count > 100``, is
never evaluated at all: it is a non-empty string, so the ``if`` step always
takes ``then`` and a ``while``/``do-while`` step always runs to
``max_iterations``.

That is the same silent-truthiness authoring mistake the step validators
already reject for a list/dict/number condition, and it is easy to write:
GitHub Actions accepts a bare expression in ``if:``.

An empty/whitespace string is excluded — it coerces to ``False``, which is
a definite answer rather than a silent always-true.
"""
if not isinstance(condition, str):
return False
stripped = condition.strip()
if not stripped or stripped.lower() in ("true", "false"):
return False
open_at = stripped.find("{{")
if open_at == -1:
return True
# An opening ``{{`` the substituter cannot close is no better than a missing
# one. ``_interpolate_expressions`` closes a block with the same quote-aware
# scan used here, so ``{{ inputs.count > 100`` -- and the reversed
# ``}} inputs.count > 100 {{``, whose only ``{{`` is last -- come back
# verbatim, while ``{{ inputs.x == '}}'`` falls to the raw-close branch and
# leaves residual text (``False'``). All three are non-empty strings that
# ``bool()`` then makes true. A plain ``find("}}")`` would miss the third,
# and would also have to re-derive quote handling this module already owns.
return _find_block_close(stripped, open_at) == -1


def format_condition_correction(condition: Any) -> str:
"""Render *condition* wrapped in ``{{ }}`` as a quoted, paste-ready YAML scalar.

The validators hand this back as the corrected form, so it has to survive a
round trip through a YAML parser. A plain ``"{{ ... }}"`` does not: a
condition holding a double quote (``inputs.name == "zzz"``) closes the
scalar early and the workflow file no longer loads. Quoting is therefore
chosen from the content. That enumeration was incomplete: a condition loaded
from a YAML literal block can carry a newline, which a double-quoted scalar
folds, so the correction did not round-trip.

``json.dumps`` decides it instead. Every JSON string is a valid YAML
double-quoted scalar, and it escapes the quotes, backslashes, newlines and
other control characters that hand-rolled quoting has to enumerate.
``ensure_ascii=False`` keeps non-ASCII operands readable rather than
expanding them into numeric escapes.

A stray delimiter is dropped rather than nested: ``{{ inputs.count > 100``
corrects to ``"{{ inputs.count > 100 }}"``, not to a doubled ``{{ {{ ... }} }}``.
"""
core = str(condition).strip()
core = re.sub(r"^\s*(\{\{|\}\})\s*", "", core)
core = re.sub(r"\s*(\{\{|\}\})\s*$", "", core).strip()
return json.dumps("{{ " + core + " }}", ensure_ascii=False)
18 changes: 18 additions & 0 deletions src/specify_cli/workflows/steps/do_while/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
from typing import Any

from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import (
condition_is_never_evaluated,
format_condition_correction,
)


class DoWhileStep(StepBase):
Expand Down Expand Up @@ -88,6 +92,20 @@ def validate(self, config: dict[str, Any]) -> list[str]:
f"Do-while step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
elif condition_is_never_evaluated(config["condition"]):
# A string condition with no ``{{ }}`` block is never evaluated:
# evaluate_expression() returns it unchanged and bool() then makes
# any non-empty text true. `condition: inputs.count > 100` reads as
# a real comparison but always takes every iteration. This is the same
# silent-truthiness mistake the list/dict branch above rejects, and
# GitHub Actions accepts a bare expression in `if:`, so it is easy
# to write by habit.
errors.append(
f"Do-while step {config.get('id', '?')!r}: 'condition' "
f"{config['condition']!r} has no complete '{{{{ }}}}' block, so it is "
"never evaluated and is always true. Wrap the expression: "
+ format_condition_correction(config["condition"]) + "."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and
Expand Down
20 changes: 19 additions & 1 deletion src/specify_cli/workflows/steps/if_then/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from typing import Any

from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_condition
from specify_cli.workflows.expressions import (
condition_is_never_evaluated,
format_condition_correction,
evaluate_condition,
)


class IfThenStep(StepBase):
Expand Down Expand Up @@ -79,6 +83,20 @@ def validate(self, config: dict[str, Any]) -> list[str]:
f"If step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
elif condition_is_never_evaluated(config["condition"]):
# A string condition with no ``{{ }}`` block is never evaluated:
# evaluate_expression() returns it unchanged and bool() then makes
# any non-empty text true. `condition: inputs.count > 100` reads as
# a real comparison but always takes ``then``. This is the same
# silent-truthiness mistake the list/dict branch above rejects, and
# GitHub Actions accepts a bare expression in `if:`, so it is easy
# to write by habit.
errors.append(
f"If step {config.get('id', '?')!r}: 'condition' "
f"{config['condition']!r} has no complete '{{{{ }}}}' block, so it is "
"never evaluated and is always true. Wrap the expression: "
+ format_condition_correction(config["condition"]) + "."
)
if "then" not in config:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'then' field."
Expand Down
20 changes: 19 additions & 1 deletion src/specify_cli/workflows/steps/while_loop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from typing import Any

from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_condition
from specify_cli.workflows.expressions import (
condition_is_never_evaluated,
format_condition_correction,
evaluate_condition,
)


class WhileStep(StepBase):
Expand Down Expand Up @@ -97,6 +101,20 @@ def validate(self, config: dict[str, Any]) -> list[str]:
f"While step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
elif condition_is_never_evaluated(config["condition"]):
# A string condition with no ``{{ }}`` block is never evaluated:
# evaluate_expression() returns it unchanged and bool() then makes
# any non-empty text true. `condition: inputs.count > 100` reads as
# a real comparison but always takes every iteration. This is the same
# silent-truthiness mistake the list/dict branch above rejects, and
# GitHub Actions accepts a bare expression in `if:`, so it is easy
# to write by habit.
errors.append(
f"While step {config.get('id', '?')!r}: 'condition' "
f"{config['condition']!r} has no complete '{{{{ }}}}' block, so it is "
"never evaluated and is always true. Wrap the expression: "
+ format_condition_correction(config["condition"]) + "."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and
Expand Down
169 changes: 169 additions & 0 deletions tests/unit/test_condition_expression_block.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""A string condition with no ``{{ }}`` block is never evaluated (always true)."""

import pytest
import yaml

from specify_cli.workflows.base import StepContext
from specify_cli.workflows.expressions import (
condition_is_never_evaluated,
evaluate_condition,
format_condition_correction,
)
from specify_cli.workflows.steps.do_while import DoWhileStep
from specify_cli.workflows.steps.if_then import IfThenStep
from specify_cli.workflows.steps.while_loop import WhileStep

STEP_CLASSES = [IfThenStep, WhileStep, DoWhileStep]


@pytest.mark.parametrize(
"condition",
["inputs.count > 100", "inputs.name == 'zzz'", "inputs.count < 3"],
)
def test_brace_less_condition_is_always_true_at_runtime(condition):
"""The behaviour the validator now warns about, pinned so it cannot drift."""
ctx = StepContext(inputs={"count": 5, "name": "abc"})
# Same expression with braces resolves to its real (false) value...
assert evaluate_condition("{{ " + condition + " }}", ctx) is False
# ...without them it is only non-empty text, so bool() makes it true.
assert evaluate_condition(condition, ctx) is True


@pytest.mark.parametrize("step_cls", STEP_CLASSES)
def test_validator_rejects_condition_without_expression_block(step_cls):
config = {"id": "s1", "condition": "inputs.count > 100", "then": [], "steps": []}
errors = [e for e in step_cls().validate(config) if "never evaluated" in e]
assert len(errors) == 1
assert "inputs.count > 100" in errors[0]
# The message hands back the corrected form.
assert '"{{ inputs.count > 100 }}"' in errors[0]


@pytest.mark.parametrize("step_cls", STEP_CLASSES)
@pytest.mark.parametrize(
"condition",
["{{ inputs.count > 100 }}", "true", "false", "TRUE", True, False, "", " "],
)
def test_validator_accepts_evaluated_and_literal_conditions(step_cls, condition):
"""No false positives: braces, boolean literals and bools stay valid."""
config = {"id": "s1", "condition": condition, "then": [], "steps": []}
assert not [e for e in step_cls().validate(config) if "never evaluated" in e]


@pytest.mark.parametrize(
("value", "expected"),
[
("inputs.count > 100", True),
("{{ inputs.count > 100 }}", False),
("prefix {{ inputs.a }} suffix", False),
("true", False),
("False", False),
("", False),
(" ", False),
(True, False),
(["a"], False),
(3, False),
],
)
def test_condition_is_never_evaluated(value, expected):
assert condition_is_never_evaluated(value) is expected


# --- An unterminated ``{{`` is the same defect, not a different one -----------
#
# ``_interpolate_expressions`` substitutes nothing when no ``}}`` follows the
# opening ``{{`` (its ``raw_close == -1`` branch appends the tail verbatim), so
# ``{{ inputs.count > 100`` is returned unchanged and coerced to true exactly
# like a brace-less string.

BACKSLASH = chr(92)

NEVER_EVALUATED = [
"inputs.count > 100", # no delimiter at all
"{{ inputs.count > 100", # opened, never closed
"}} inputs.count > 100 {{", # reversed: the only '{{' is last
# The only '}}' sits inside a string operand, so the quote-aware scan finds
# no close. The raw-close fallback then evaluates a truncated body and
# leaves residual text ("False'"), which bool() makes true just the same.
"{{ inputs.x == '}}'",
]


@pytest.mark.parametrize("condition", NEVER_EVALUATED)
def test_incomplete_block_is_silently_true_and_is_flagged(condition):
ctx = StepContext(inputs={"count": 5, "name": "abc"})
assert evaluate_condition(condition, ctx) is True
assert condition_is_never_evaluated(condition) is True


@pytest.mark.parametrize(
"condition",
[
"{{ inputs.count > 100 }}",
"{{ inputs.a }} and {{ inputs.b }}",
"{{ inputs.text | default('}}') }}", # literal '}}' inside an argument
"{{ inputs.x == '}}' }}", # quoted '}}' then the real close
],
)
def test_complete_block_is_not_flagged(condition):
assert condition_is_never_evaluated(condition) is False


# --- The suggested correction has to survive a YAML round trip ---------------

TRICKY_CONDITIONS = [
"inputs.count > 100",
'inputs.name == "zzz"', # double quote
"inputs.name == 'zzz'", # single quote
'inputs.a == "x" and inputs.b == \'y\'', # both
"inputs.path == 'C:" + BACKSLASH + "tmp'", # backslash
'inputs.path == "C:' + BACKSLASH + 'tmp"', # backslash + quote
'{{ inputs.name == "zzz"', # incomplete + quote
"}} inputs.count > 100 {{",
# A YAML literal block hands the loader a real newline; a folded scalar
# would lose it, so the correction has to escape rather than embed it.
"inputs.x == 1\nand inputs.name == 'abc'",
'he said "hi"\nthen left', # newline + quote
"inputs.a == 'x\ty'", # tab
"inputs.a == 'x\ry'", # carriage return
"inputs.ten == 'mười'", # non-ASCII operand
]


@pytest.mark.parametrize("condition", TRICKY_CONDITIONS)
def test_correction_is_valid_yaml_and_round_trips(condition):
"""A correction the author cannot paste into their workflow is no correction."""
loaded = yaml.safe_load("condition: " + format_condition_correction(condition))
stripped = condition.strip().lstrip("{}").rstrip("{}").strip()
assert loaded["condition"] == "{{ " + stripped + " }}"


@pytest.mark.parametrize("condition", TRICKY_CONDITIONS)
def test_correction_does_not_trip_the_validator_again(condition):
loaded = yaml.safe_load("condition: " + format_condition_correction(condition))
assert condition_is_never_evaluated(loaded["condition"]) is False


@pytest.mark.parametrize("condition", ["{{ inputs.count > 100", "}} a > 1 {{"])
def test_correction_replaces_a_stray_delimiter_instead_of_nesting_one(condition):
corrected = format_condition_correction(condition)
assert "{{ {{" not in corrected and "}} }}" not in corrected
assert corrected.count("{{") == 1 and corrected.count("}}") == 1


@pytest.mark.parametrize("step_cls", STEP_CLASSES)
@pytest.mark.parametrize("condition", ['inputs.name == "zzz"', "{{ inputs.count > 100"])
def test_validator_correction_is_yaml_safe(step_cls, condition):
config = {"id": "s1", "condition": condition, "then": [], "steps": []}
errors = [e for e in step_cls().validate(config) if "never evaluated" in e]
assert len(errors) == 1
suggested = errors[0].split("Wrap the expression: ", 1)[1].rstrip(".")
loaded = yaml.safe_load("condition: " + suggested)
assert condition_is_never_evaluated(loaded["condition"]) is False


def test_correction_keeps_non_ascii_readable():
"""ensure_ascii=False: an operand should not turn into numeric escapes."""
corrected = format_condition_correction("inputs.ten == 'mười'")
assert "mười" in corrected
assert chr(92) + "u" not in corrected