hermes-agent/tests/scripts/test_footgun_subprocess_encoding.py
jinglun010 051217342b feat(linter): detect subprocess text=True without explicit encoding=
Adds a new rule to scripts/check-windows-footguns.py that flags
subprocess.run/Popen/call/check_output/check_call(..., text=True, ...) calls
missing an explicit encoding= kwarg.

On Chinese Windows (cp936/GBK) and other non-UTF-8 default codepages,
text=True without encoding= decodes child output with
locale.getpreferredencoding(False), crashing _readerthread with
UnicodeDecodeError on non-default-codepage bytes (issues #47939, #53428,
rule prevents future regressions.

Rule design:
- Pattern matches 'text=True' / 'text = True'
- post_filter skips lines that:
  - already pass encoding= on the same line
  - are method definitions (def text)
  - contain text=True inside string literals
  - are not subprocess-shaped calls (heuristic via _is_likely_subprocess_call)
- Two helper functions: _is_likely_subprocess_call, _looks_like_string_literal
- Multi-line calls where subprocess.X( and text=True are on different lines
  are not flagged (acceptable false negative for a line-based scanner)

Also fixes the linter's own footgun: get_staged_files() and get_diff_files()
used subprocess.check_output(text=True) without encoding= — now fixed.

Suppresses 4 false positives on non-Windows platform-exclusive calls:
- tools/voice_mode.py (Termux/Android)
- tools/environments/singularity.py (Linux HPC)
- plugins/google_meet/cli.py (macOS system_profiler)

Test plan:
- 21 unit tests in tests/scripts/test_footgun_subprocess_encoding.py
- TestDetection: 6 cases verifying the rule flags real subprocess calls
- TestSuppression: 7 cases verifying false-positive avoidance
- TestHelpers: 7 cases for the two helper functions
- TestFullRepoScan: scans the whole tree and asserts the new rule finds
  only the 7 call sites that PR #60741 fixes (or zero, once #60741 merges)

Verified: full-repo scan reports 7 matches on main (the #60741 sites),
4 platform-exclusive calls correctly suppressed, zero false positives.
2026-07-24 11:45:57 -07:00

276 lines
11 KiB
Python

"""Tests for the ``subprocess text=True without explicit encoding=`` footgun
rule in ``scripts/check-windows-footguns.py``.
This rule (added alongside PR #60741) catches ``subprocess.run/Popen/call/
check_output/check_call(..., text=True, ...)`` calls that don't pass an
explicit ``encoding=``. On Chinese Windows (cp936/GBK) and other non-UTF-8
default codepages, ``text=True`` without ``encoding=`` decodes child output
with ``locale.getpreferredencoding(False)`` and crashes ``_readerthread``
with ``UnicodeDecodeError`` on non-default-codepage bytes.
See issues #47939, #53428, #57238.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
LINTER_PATH = REPO_ROOT / "scripts" / "check-windows-footguns.py"
def _load_linter_module():
"""Import the linter script as a module (it's not a package).
Register the module in sys.modules BEFORE exec_module so that
``@dataclass`` can resolve ``cls.__module__`` via
``sys.modules.get(cls.__module__).__dict__`` (CPython 3.11+ dataclass
internals require this).
"""
spec = importlib.util.spec_from_file_location("check_windows_footguns", LINTER_PATH)
mod = importlib.util.module_from_spec(spec)
sys.modules["check_windows_footguns"] = mod
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def linter():
return _load_linter_module()
def _find_footgun(linter, name: str):
"""Locate a Footgun by name in the FOOTGUNS list."""
for fg in linter.FOOTGUNS:
if fg.name == name:
return fg
pytest.fail(f"Footgun rule '{name}' not found in FOOTGUNS")
def _scan_line(linter, line: str, footgun_name: str) -> bool:
"""Return True if the given line triggers the named footgun rule.
Uses the linter's own pattern + post_filter logic so the test exercises
the real detection path (including guard-hint and suppression checks).
"""
fg = _find_footgun(linter, footgun_name)
# Replicate the relevant checks from scan_file(): suppression marker,
# guard hints, then pattern + post_filter.
if linter.SUPPRESS_MARKER.search(line):
return False
if any(hint in line for hint in linter.GUARD_HINTS):
return False
code = linter._strip_code(line)
if not code.strip():
return False
match = fg.pattern.search(code)
if not match:
return False
if fg.post_filter is not None:
try:
if not fg.post_filter(match, line):
return False
except (IndexError, AttributeError):
return False
return True
RULE_NAME = "subprocess text=True without explicit encoding="
# ---------------------------------------------------------------------------
# Detection — these SHOULD be flagged
# ---------------------------------------------------------------------------
class TestDetection:
def test_flags_subprocess_run_text_true_without_encoding(self, linter):
line = ' result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)'
assert _scan_line(linter, line, RULE_NAME), "expected flag for text=True without encoding="
def test_flags_subprocess_popen_text_true_without_encoding(self, linter):
line = ' p = subprocess.Popen(cmd, text=True, stdout=PIPE)'
assert _scan_line(linter, line, RULE_NAME)
def test_flags_subprocess_check_output_text_true(self, linter):
line = ' out = subprocess.check_output(["git", "status"], text=True)'
assert _scan_line(linter, line, RULE_NAME)
def test_flags_sp_alias_text_true(self, linter):
line = ' res = _sp.run(cmd, text=True, timeout=5)'
assert _scan_line(linter, line, RULE_NAME)
def test_flags_text_with_spaces_around_equals(self, linter):
line = ' subprocess.run(cmd, text = True, timeout=10)'
assert _scan_line(linter, line, RULE_NAME)
def test_flags_bare_run_call(self, linter):
# .run( without explicit subprocess. prefix — still a subprocess call
line = ' result = obj.run(cmd, text=True)'
assert _scan_line(linter, line, RULE_NAME)
# ---------------------------------------------------------------------------
# Suppression — these should NOT be flagged
# ---------------------------------------------------------------------------
class TestSuppression:
def test_does_not_flag_when_encoding_present(self, linter):
line = ' subprocess.run(cmd, text=True, encoding="utf-8", errors="replace")'
assert not _scan_line(linter, line, RULE_NAME)
def test_does_not_flag_when_encoding_with_spaces(self, linter):
line = " subprocess.run(cmd, text=True, encoding = 'utf-8')"
assert not _scan_line(linter, line, RULE_NAME)
def test_does_not_flag_inline_suppression_marker(self, linter):
line = ' subprocess.run(cmd, text=True) # windows-footgun: ok — POSIX only'
assert not _scan_line(linter, line, RULE_NAME)
def test_does_not_flag_non_subprocess_text_kwarg(self, linter):
# DataFrame.rename(text=True) — not a subprocess call
line = ' df = df.rename(text=True)'
assert not _scan_line(linter, line, RULE_NAME), (
"should not flag non-subprocess APIs that accept text= kwarg"
)
def test_does_not_flag_text_true_in_string_literal(self, linter):
line = ' """See subprocess.run(text=True) for details."""'
assert not _scan_line(linter, line, RULE_NAME), (
"should not flag text=True inside docstrings"
)
def test_does_not_flag_def_text_method(self, linter):
line = ' def text(self, value: bool = True):'
assert not _scan_line(linter, line, RULE_NAME)
def test_does_not_flag_comment_only_line(self, linter):
line = ' # subprocess.run(cmd, text=True) — example'
assert not _scan_line(linter, line, RULE_NAME)
# ---------------------------------------------------------------------------
# Helper functions — unit tests for _is_likely_subprocess_call and
# _looks_like_string_literal
# ---------------------------------------------------------------------------
class TestHelpers:
def test_is_likely_subprocess_call_matches_subprocess_run(self, linter):
assert linter._is_likely_subprocess_call("subprocess.run(cmd, text=True)")
def test_is_likely_subprocess_call_matches_bare_run(self, linter):
assert linter._is_likely_subprocess_call("result = obj.run(cmd, text=True)")
def test_is_likely_subprocess_call_rejects_dataframe(self, linter):
assert not linter._is_likely_subprocess_call("df.rename(text=True)")
def test_is_likely_subprocess_call_rejects_plain_assignment(self, linter):
assert not linter._is_likely_subprocess_call("config.text = True")
def test_looks_like_string_literal_double_quotes(self, linter):
import re
line = ' msg = "use text=True carefully"'
match = re.search(r"\btext\s*=\s*True\b", line)
assert match is not None
assert linter._looks_like_string_literal(line, match)
def test_looks_like_string_literal_single_quotes(self, linter):
import re
line = " msg = 'see text=True in docs'"
match = re.search(r"\btext\s*=\s*True\b", line)
assert match is not None
assert linter._looks_like_string_literal(line, match)
def test_looks_like_string_literal_false_for_real_code(self, linter):
import re
line = ' subprocess.run(cmd, text=True)'
match = re.search(r"\btext\s*=\s*True\b", line)
assert match is not None
assert not linter._looks_like_string_literal(line, match)
# ---------------------------------------------------------------------------
# Full-repo scan — after PR #60741 merges, the new rule should find ZERO
# unsuppressed violations in the whole tree (excluding the linter itself
# and CONTRIBUTING docs). This test will FAIL until PR #60741 is merged;
# mark it xfail when run on a branch that doesn't include PR #60741's fixes.
# ---------------------------------------------------------------------------
class TestFullRepoScan:
def test_new_rule_find_only_known_violations(self, linter, monkeypatch):
"""Scan the full repo and assert the new rule's matches are exactly
the set of call sites that PR #60741 fixes (or zero, if PR #60741
is already merged into this branch).
This is a regression guard: if someone adds a new
``subprocess.run(text=True)`` without ``encoding=``, this test
catches it.
"""
# The 7 call sites that PR #60741 fixes. If PR #60741 is merged
# into this branch, this set should be empty. If not, these are
# the expected matches.
pr_60741_sites = {
"hermes_cli/main.py",
"hermes_cli/onepassword_secrets_cli.py",
"hermes_cli/setup.py",
"tools/transcription_tools.py",
"tools/tts_tool.py",
}
# Run the full scan
roots = [
REPO_ROOT / "hermes_cli",
REPO_ROOT / "gateway",
REPO_ROOT / "tools",
REPO_ROOT / "cron",
REPO_ROOT / "agent",
REPO_ROOT / "plugins",
REPO_ROOT / "scripts",
REPO_ROOT / "acp_adapter",
REPO_ROOT / "acp_registry",
]
roots = [r for r in roots if r.exists()]
fg = _find_footgun(linter, RULE_NAME)
new_rule_matches: dict[str, list[int]] = {}
for path in linter.iter_files(roots):
matches = linter.scan_file(path, [fg]) # scan with ONLY the new rule
if matches:
rel = path.relative_to(REPO_ROOT).as_posix()
new_rule_matches[rel] = [m[0] for m in matches]
# Determine which sites remain. PR #60741's fixes are on a separate
# branch; if this branch doesn't include them, the 7 call sites
# will still be flagged — that's expected, not a failure.
if new_rule_matches:
# Filter out the linter itself (it mentions text=True in its
# own pattern/message, but EXCLUDED_FILES handles that for the
# CLI entry point; the helper functions could trip it).
new_rule_matches = {
k: v for k, v in new_rule_matches.items()
if k != "scripts/check-windows-footguns.py"
}
if not new_rule_matches:
# PR #60741 already merged — clean tree. This is the goal state.
return
# Matches remain — they must be exactly the PR #60741 sites.
matched_files = set(new_rule_matches.keys())
unexpected = matched_files - pr_60741_sites
if unexpected:
pytest.fail(
f"New footgun rule found UNEXPECTED matches in files not "
f"covered by PR #60741: {sorted(unexpected)}.\n"
f"These are either new regressions or call sites that need "
f"a `# windows-footgun: ok` suppression."
)
# All matches are the expected PR #60741 sites — OK on this branch.