Skip to content
Merged
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
20 changes: 14 additions & 6 deletions src/specify_cli/workflows/steps/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,20 @@ def _timeout_error(config: dict[str, Any]) -> str | None:
if "timeout" not in config:
return None
timeout = config["timeout"]
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or timeout <= 0
):
try:
invalid_timeout = (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or timeout <= 0
)
except OverflowError:
# An int too large to convert to float (e.g. a 400-digit YAML
# scalar) is not a bool and *is* an int, so it clears every clause
# before ``isfinite()`` and raises there — and would raise the same
# from subprocess.run(timeout=...). Mirrors the prompt step.
invalid_timeout = True
if invalid_timeout:
return (
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."
Expand Down
69 changes: 69 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -2027,6 +2027,75 @@ def test_validate_rejects_non_finite_timeout(self):
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
assert any("'timeout' must be a positive number" in e for e in errors)

def test_validate_rejects_huge_int_timeout(self):
"""A too-large-to-convert int must be reported, not raise OverflowError.

``math.isfinite(10**400)`` raises ``OverflowError: int too large to
convert to float``. Such a value is an ``int`` and is not a ``bool``,
so it clears every clause before ``isfinite()`` and raises there —
escaping ``validate()`` as the uncaught crash this guard exists to
prevent. ``specify workflow run`` then aborts with a bare traceback
instead of "Workflow validation failed". Both signs reach
``isfinite()`` because it is checked before ``timeout <= 0``.
``subprocess.run(timeout=...)`` raises the same OverflowError, so the
value is genuinely invalid rather than merely unrepresentable here.
The prompt step already catches this (PR #3847).
"""
from specify_cli.workflows.steps.shell import ShellStep

step = ShellStep()
for bad in (10**400, -(10**400)):
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
assert any(
"'timeout' must be a positive number" in e for e in errors
), (bad, errors)

def test_validate_workflow_reports_huge_int_timeout(self):
"""The huge-int timeout surfaces as a validation error end to end.

``specify workflow run`` calls ``engine.validate()`` before executing
any step; an OverflowError escaping the shell step's ``validate()``
propagates out of ``validate_workflow`` and kills the command with a
traceback, so pin the whole path, not just the helper.
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow

definition = WorkflowDefinition(
{
"schema_version": "1.0",
"workflow": {"id": "demo", "name": "Demo", "version": "1.0.0"},
"steps": [
{"id": "qa", "type": "shell", "run": "echo hi", "timeout": 10**400}
],
}
)
errors = validate_workflow(definition)
assert any("'timeout' must be a positive number" in e for e in errors), errors

def test_execute_fails_cleanly_on_huge_int_timeout(self, monkeypatch):
"""execute() must fail just this step on a huge-int timeout.

The engine does not auto-validate step config and re-raises anything a
step throws, so on an unvalidated run the OverflowError would abort the
whole workflow after earlier steps had already run their side effects.
"""
import subprocess

from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus

def fail_if_called(*args, **kwargs):
raise AssertionError("subprocess.run should not run on invalid timeout")

monkeypatch.setattr(subprocess, "run", fail_if_called)
step = ShellStep()
for bad in (10**400, -(10**400)):
result = step.execute(
{"id": "qa", "run": "echo hi", "timeout": bad}, StepContext()
)
assert result.status == StepStatus.FAILED, bad
assert "'timeout' must be a positive number" in (result.error or ""), bad

def test_validate_accepts_positive_numeric_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep

Expand Down