diff options
Diffstat (limited to 'src/ansiblelint/rules/no_free_form.py')
-rw-r--r-- | src/ansiblelint/rules/no_free_form.py | 107 |
1 files changed, 107 insertions, 0 deletions
diff --git a/src/ansiblelint/rules/no_free_form.py b/src/ansiblelint/rules/no_free_form.py new file mode 100644 index 0000000..5a23e8b --- /dev/null +++ b/src/ansiblelint/rules/no_free_form.py @@ -0,0 +1,107 @@ +"""Implementation of NoFreeFormRule.""" +from __future__ import annotations + +import re +import sys +from typing import TYPE_CHECKING, Any + +from ansiblelint.constants import INCLUSION_ACTION_NAMES, LINE_NUMBER_KEY +from ansiblelint.errors import MatchError +from ansiblelint.rules import AnsibleLintRule + +if TYPE_CHECKING: + from ansiblelint.file_utils import Lintable + + +class NoFreeFormRule(AnsibleLintRule): + """Rule for detecting discouraged free-form syntax for action modules.""" + + id = "no-free-form" + description = "Avoid free-form inside files as it can produce subtile bugs." + severity = "MEDIUM" + tags = ["syntax", "risk"] + version_added = "v6.8.0" + needs_raw_task = True + cmd_shell_re = re.compile( + r"(chdir|creates|executable|removes|stdin|stdin_add_newline|warn)=" + ) + + def matchtask( + self, task: dict[str, Any], file: Lintable | None = None + ) -> list[MatchError]: + results: list[MatchError] = [] + action = task["action"]["__ansible_module_original__"] + + if action in INCLUSION_ACTION_NAMES: + return results + + action_value = task["__raw_task__"].get(action, None) + if task["action"].get("__ansible_module__", None) == "raw": + if isinstance(action_value, str): + if "executable=" in action_value: + results.append( + self.create_matcherror( + message="Avoid embedding `executable=` inside raw calls, use explicit args dictionary instead.", + linenumber=task[LINE_NUMBER_KEY], + filename=file, + tag=f"{self.id}[raw]", + ) + ) + else: + results.append( + self.create_matcherror( + message="Passing a non string value to `raw` module is neither documented or supported.", + linenumber=task[LINE_NUMBER_KEY], + filename=file, + tag=f"{self.id}[raw-non-string]", + ) + ) + elif isinstance(action_value, str) and "=" in action_value: + fail = False + if task["action"].get("__ansible_module__") in ( + "ansible.builtin.command", + "ansible.builtin.shell", + "ansible.windows.win_command", + "ansible.windows.win_shell", + "command", + "shell", + "win_command", + "win_shell", + ): + if self.cmd_shell_re.match(action_value): + fail = True + else: + fail = True + if fail: + results.append( + self.create_matcherror( + message=f"Avoid using free-form when calling module actions. ({action})", + linenumber=task[LINE_NUMBER_KEY], + filename=file, + ) + ) + return results + + +if "pytest" in sys.modules: # noqa: C901 + import pytest + + from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports + from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + + @pytest.mark.parametrize( + ("file", "expected"), + ( + pytest.param("examples/playbooks/rule-no-free-form-pass.yml", 0, id="pass"), + pytest.param("examples/playbooks/rule-no-free-form-fail.yml", 2, id="fail"), + ), + ) + def test_rule_no_free_form( + default_rules_collection: RulesCollection, file: str, expected: int + ) -> None: + """Validate that rule works as intended.""" + results = Runner(file, rules=default_rules_collection).run() + + for result in results: + assert result.rule.id == NoFreeFormRule.id, result + assert len(results) == expected |