fix(intents): an ADB-off entry never scans, and a truncated detail is never believed (t21)

The t18 review's three findings, closed with the reviewer's own repro.

F1 (HIGH — the harm was real shells, not a wrong label). `async_scan_intents`
decided between `adb_disabled` and `adb_unavailable` INSIDE `if self._poller is
None:`, so a manager that HELD a poller fell through to the shell loop: with
`adb_enabled: False` and a connected poller the review measured status `fresh`,
one candidate CACHED, and `pm dump com.x …` really sent. The option is a fact
about the ENTRY, so it is now checked once at the top and returns before
anything else — no cache read, no cache write, no shell. The no-poller branch
keeps the OTHER honest answer: with the option ON and nobody to ask, the status
is `adb_unavailable` ("wait or reconnect"), never `adb_disabled` ("go turn the
option on"), because the two have different fixes. Five tests pin it, including
a cache-leak case (a manager with a cached answer whose option is then switched
off answers `adb_disabled`, not the cache) and both statuses with a FORCED scan.

F2 (low, but it fabricated a value that looked real). A capture cut mid-detail
produced `Scheme: "ht` → scheme `"ht` and `Authority: "www.exam` → that
authority, and `_candidate_data` would then prefill the form with a URL no app
can match. Not an injection (everything stays quoted when used), which is
exactly why nothing caught it. `_unquote` now DROPS a value that opens a quote
and never closes it (a lone quote too), so the callers turn it into None: the
candidate survives with its intact action/component and simply carries no
scheme/authority/path — and therefore prefills NO data instead of a broken URL.
A complete quoted value is still read (the anti-overcorrection pin), and the
consequence is pinned at the consumption end too, through the real options-flow
helper.

F3 (low). The floor comment now answers the review in writing, where the next
reader looks: the t18 review's measurement (awaited_calls 6 against a floor of 5,
three other counters within 2) is named, and the verdict is recorded — those ARE
the policy margins (2 for owner-counters because an owner really can be
refactored away, 1 for awaited_calls because six sites cannot carry a percentage
band). The counters were genuinely updated, not dodged: raised to the real sweep
when the code grew (20→24, 70→75, 235→275, 55→63, 1400→1620) and re-derived
twice since (t20 1620→1615, t21 1615→1617 for this unit's blocks).

EVIDENCE THAT THE TESTS PIN THE FIX (not merely pass): the same files were run
against the PRE-fix source (git archive HEAD, a throwaway copy under /tmp) — 9 of
the 11 new tests FAIL there, including the reviewer's repro, which fails with
`assert 'fresh' == 'adb_disabled'`. The two that pass are the deliberate
anti-overcorrection pins: a COMPLETE quoted value is still parsed, and the
no-poller control keeps its own status.

NO REGRESSION: 900 passed / 5 skipped, 901 / 4 with androidtvremote2; the
injection proof, the fixture's candidates with dedup/flagging/order/bound
disclosures, both cache outcomes, the registration round trip through the real
FlowManager, button lifecycle and defaults, the YAML seed rule and §7.1
(supports_response optional, a real return_response call, a no-response call) all
still pass. The strings files are byte-identical (untouched here). Real-HA pattern
collected and RAN 3 files → 74 passed / 0 skipped. Drift exit 0 (1650 blocks).

NOT VERIFIED HERE: the device side remains a scripted double — no real Shield was
asked for its resolver table, so the shell COUNT and the status vocabulary are
what is proven, not real firmware behaviour. No browser either: the form prefill
is asserted on the values the helper returns.
This commit is contained in:
James Mackay committed 2026-10-12 13:14:34 +01:00
1 parent 6b866019ef
commit 8f064b3247
5 files changed
+263 -15

No files matched your search

+25 -6
View File
@@ -1684,6 +1684,22 @@ class ShieldConnectionManager:
# A malformed id never reaches a shell, and never looks like an
# answer: the same rule the sweep applies to the rows it keeps.
return IntentScanResult(package=name, status=INTENT_SCAN_INVALID_PACKAGE)
# t21/F1: THE OPTION IS CHECKED FIRST, ONCE, AND IT RETURNS BEFORE ANYTHING
# ELSE — no cache read, no cache write, no shell. This check used to live
# INSIDE the no-poller branch below, so an entry that HOLDS a poller while
# its own option says ADB is off fell straight through to the dump: the
# status was merely mislabelled, but the SHELL WAS REAL, and a scan the
# entry was not allowed to run was CACHED as an answer (measured by the t18
# review: status `fresh`, one candidate cached, `pm dump com.x …` sent).
# "ADB is switched off" is a fact about the ENTRY, so it is decided from the
# entry — never inferred from which objects happen to exist.
if self._adb_disabled():
_LOGGER.debug(
"intent scan for %s not attempted (ADB is switched off for this "
"entry) — no shell, nothing cached",
name,
)
return IntentScanResult(package=name, status=INTENT_SCAN_ADB_DISABLED)
if not force:
cached = self._intents.get(name)
if cached is not None:
@@ -1696,13 +1712,16 @@ class ShieldConnectionManager:
truncated=cached.truncated,
)
if self._poller is None:
status = (
INTENT_SCAN_ADB_DISABLED
if self._adb_disabled()
else INTENT_SCAN_ADB_UNAVAILABLE
# t21/F1: no poller is NOT the same fact as ADB being switched off. The
# option above said ADB is ON, so the honest answer is "the device
# cannot be asked right now" (wait, or reconnect), never `adb_disabled`
# (go turn the option on) — the two have different fixes.
_LOGGER.debug(
"intent scan for %s not attempted (ADB is on for this entry but "
"there is no poller to ask) — no shell, nothing cached",
name,
)
_LOGGER.debug("intent scan for %s not attempted (%s)", name, status)
return IntentScanResult(package=name, status=status)
return IntentScanResult(package=name, status=INTENT_SCAN_ADB_UNAVAILABLE)
if not self.connected:
_LOGGER.debug(
"intent scan for %s not attempted (ADB connected: no) — the "
@@ -174,10 +174,23 @@ _ANSWERED_STATUSES = (
def _unquote(value: str | None) -> str:
"""Strip one layer of matching quotes, if any (``"x"`` -> ``x``)."""
"""Strip one layer of matching quotes; DROP an unterminated one (t21/F2).
``"x"`` -> ``x``. A value that OPENS a quote and never closes it is not a
value we know: the capture was cut mid-detail (``Scheme: "ht``,
``Authority: "www.exam``, ``Path: "PatternMatcher{GLOB: /title``), and
believing the leftover produced a scheme, host or pattern that LOOKS real
while no app can match it — `_candidate_data` would then prefill the form
with a URL that cannot work. An unterminated value is therefore dropped
(empty here; the callers turn that into None), so a corrupt detail is never
mistaken for a real one. It was never an injection either way (every value is
quoted when it is used), which is exactly why the failure was invisible.
"""
text = (value or "").strip()
if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
if text[:1] in ("'", '"'):
if len(text) >= 2 and text[-1] == text[0]:
return text[1:-1].strip()
return ""
return text
+23 -7
View File
@@ -143,7 +143,7 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
#: Non-vacuity floors — the ONE source of truth for the standalone entry point
#: and the pytest wrapper (t39 F1). They sit below the current walk (26 modules,
#: 77 imports, 282 relative imports, 65 checked arguments, 6 awaited HA calls,
#: 1648 statement blocks) so a broken or mis-targeted walk fails loudly instead of
#: 1650 statement blocks) so a broken or mis-targeted walk fails loudly instead of
#: printing zeros and exiting 0.
#:
#: THE MARGIN POLICY (t20/O3 — the t19 review asked for this in writing). A floor
@@ -166,11 +166,24 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
#: t19 complaint: at 1300 vs 1322 a 23-statement refactor tripped it). A 2%
#: band leaves a normal refactor alone while a parse failure still shows up as
#: a large drop.
#: * awaited_calls: 1 (5 vs 6). Stated plainly: with six awaited HA calls a
#: percentage margin is meaningless, so this floor is the weakest one here. It
#: is still worth having — a walk that stopped visiting `await` sites at all
#: trips it — but a deliberate removal of an awaited call MUST lower this floor
#: in the same commit, and that is the reviewable act the gate exists for.
#: * awaited_calls: 1 (measured 6, floor 5 — DELIBERATE, see below). Stated
#: plainly: with six awaited HA calls a percentage margin is meaningless, so
#: this floor is the weakest one here. It is still worth having — a walk that
#: stopped visiting `await` sites at all trips it — but a deliberate removal of
#: an awaited call MUST lower this floor in the same commit, and that is the
#: reviewable act the gate exists for.
#:
#: t18/F3 asked whether these margins are deliberate and whether the counters were
#: genuinely updated rather than dodged. Both answers are yes, and they are recorded
#: HERE rather than in a task log: the t18 review measured awaited_calls at 6
#: against this floor of 5 and three other counters within 2 (modules 26/24,
#: imports 77/75, call_args 65/63 — each named in its own comment below), and the
#: verdict is that those ARE the policy margins above: 2 for owner-counters because
#: an owner really can be refactored away, 1 for awaited_calls because the counter
#: is too small to do better. The counters themselves were RAISED to the real sweep
#: when the code grew (20→24, 70→75, 235→275, 55→63, 1400→1620) and re-derived
#: twice since (t20: 1620→1615; t21: 1615→1617 after this unit's blocks), never
#: lowered to dodge a failure and never left above the walk.
#:
#: The rule for changing any of them: lower a floor in the SAME commit that removes
#: the code it counts, and never raise one without re-running the sweep first (a
@@ -231,7 +244,10 @@ FLOORS: dict[str, int] = {
# t20: the walk is 1648 after the protocol lifecycle fixes (the tracked
# attempt, the cancel-and-await stop and the transition banner are new blocks),
# so the floor is re-derived from the CURRENT walk at the 2% band above.
"statement_blocks": 1615,
# t21: the walk is 1650 (the ADB-off guard returns before the scan, and the
# unterminated-value check is a few blocks more), so the 2% band is re-derived
# again: 1617.
"statement_blocks": 1617,
}
#: The interpreter floor Home Assistant itself declares: PyPI metadata for
+170
View File
@@ -404,6 +404,100 @@ class TestRankingAndHonesty:
assert is_likely_internal(None, "com.x/ConstraintProxy$Foo") is True
class TestATruncatedValueIsNotFabricated:
"""t18/F2: a capture cut mid-value must not yield a PARTIAL value.
Android prints quoted detail values (``Scheme: "https"``, ``Authority:
"www.netflix.com": -1``, ``Path: "PatternMatcher{GLOB: /title.*}"``). A dump
truncated inside one of them used to produce ``"ht`` / ``"www.exam`` — values
that LOOK real, which ``_candidate_data`` would then turn into a URL the app
cannot match. Not an injection (everything stays quoted when used), which is
exactly why nothing else caught it: the corrupt value must be distinguishable
from a real one, so an unterminated quoted value is DROPPED.
"""
#: Every quoted detail cut before its closing quote — the reviewer's cases,
#: plus a cut in the middle of the Path matcher.
TRUNCATED = (
"Activity Resolver Table:\n"
" Schemes:\n"
" https:\n"
" deca7fa com.netflix.ninja/.MainActivity filter fc1\n"
' Action: "android.intent.action.VIEW"\n'
' Scheme: "ht\n'
' Authority: "www.exam\n'
' Path: "PatternMatcher{GLOB: /title\n'
"Service Resolver Table:\n"
)
def test_the_partial_values_are_dropped_not_kept(self):
[candidate] = parse_activity_resolver_table(self.TRUNCATED)
assert candidate.scheme is None, "a half scheme is not a scheme"
assert candidate.authority is None, "a half host is not a host"
assert candidate.path is None, "a half pattern is not a path"
assert candidate.path_kind is None
def test_the_intact_details_survive_and_nothing_carries_a_quote(self):
[candidate] = parse_activity_resolver_table(self.TRUNCATED)
assert candidate.action == "android.intent.action.VIEW", (
"the value that WAS complete is still read"
)
assert candidate.component == "com.netflix.ninja/.MainActivity"
for value in (candidate.scheme, candidate.authority, candidate.path):
assert value is None or not value.startswith(("'", '"')), value
def test_an_unterminated_value_at_the_very_end_of_the_input(self):
"""The last line can be the cut one — the end of input is not a terminator."""
cut = (
"Activity Resolver Table:\n"
" Schemes:\n"
" https:\n"
" deca7fa com.netflix.ninja/.MainActivity filter fc1\n"
' Scheme: "ht'
)
[candidate] = parse_activity_resolver_table(cut)
assert candidate.scheme is None
assert candidate.authority is None
assert candidate.path is None
def test_a_lone_quote_is_dropped_too(self):
cut = (
"Activity Resolver Table:\n"
" Schemes:\n"
" https:\n"
" deca7fa com.netflix.ninja/.MainActivity filter fc1\n"
' Scheme: "\n'
)
[candidate] = parse_activity_resolver_table(cut)
assert candidate.scheme is None
def test_a_complete_quoted_value_is_still_read(self):
"""The anti-overcorrection pin: dropping must not eat good values."""
intact = (
"Activity Resolver Table:\n"
" Schemes:\n"
" https:\n"
" deca7fa com.netflix.ninja/.MainActivity filter fc1\n"
' Action: "android.intent.action.VIEW"\n'
' Scheme: "https"\n'
' Authority: "www.netflix.com": -1\n'
' Path: "PatternMatcher{LITERAL, /browse}"\n'
"Service Resolver Table:\n"
)
[candidate] = parse_activity_resolver_table(intact)
assert candidate.scheme == "https"
assert candidate.authority == "www.netflix.com"
assert candidate.path == "/browse"
assert candidate.path_kind == "LITERAL"
class TestDegradation:
"""A shape we have never seen degrades to NO candidate, never a wrong one."""
@@ -848,3 +942,79 @@ class TestTheManagerScan:
assert len(result.candidates) == INTENT_CANDIDATE_LIMIT
assert result.truncated is True, "a capped answer never looks complete"
assert manager.intent_cache.get(NETFLIX).truncated is True
class TestAdbDisabledNeverTouchesTheDevice:
"""t18/F1: an entry whose OPTION says ADB is off must not scan at all.
The review measured the defect: with ``adb_enabled: False`` and a CONNECTED
poller, the scan fell straight through to the shell loop — ``pm dump com.x …``
was really sent, one candidate was CACHED, and the status read ``fresh`` (a
silent poller made it ``adb_unavailable``, so even the label depended on the
wrong thing). "ADB is switched off" is a fact about the ENTRY, so the option is
checked once at the top and returns before any shell or cache touch.
"""
async def test_a_held_poller_does_not_override_the_option(self):
poller = _IntentsPoller(default=NETFLIX_DUMP) # connected, would answer
manager = _manager(poller, adb_enabled=False)
result = await manager.async_scan_intents(NETFLIX)
assert result.status == INTENT_SCAN_ADB_DISABLED
assert result.answered is False
assert result.candidates == ()
assert poller.shell_calls == [], "the device was never touched"
assert manager.intent_scan_state(NETFLIX) is None, "nothing was cached"
async def test_a_forced_scan_is_refused_the_same_way(self):
poller = _IntentsPoller(default=NETFLIX_DUMP)
manager = _manager(poller, adb_enabled=False)
assert (await manager.async_scan_intents(NETFLIX)).status == (
INTENT_SCAN_ADB_DISABLED
)
forced = await manager.async_scan_intents(NETFLIX, force=True)
assert forced.status == INTENT_SCAN_ADB_DISABLED
assert poller.shell_calls == [], "force does not override the option either"
assert manager.intent_scan_state(NETFLIX) is None
async def test_a_silent_poller_cannot_change_the_status(self):
"""The review's second half: the LABEL must come from the option."""
silent_but_disabled = _manager(_IntentsPoller(default=None), adb_enabled=False)
assert (await silent_but_disabled.async_scan_intents(NETFLIX)).status == (
INTENT_SCAN_ADB_DISABLED
)
assert silent_but_disabled._poller.shell_calls == []
# and the mirror image: ADB ON with a silent poller is "could not ask",
# never "switched off" — the two have different fixes
silent_but_enabled = _manager(_IntentsPoller(default=None), adb_enabled=True)
assert (await silent_but_enabled.async_scan_intents(NETFLIX)).status == (
INTENT_SCAN_ADB_UNAVAILABLE
)
async def test_a_cached_answer_cannot_leak_out_of_a_disabled_entry(self):
"""ADB off is answered BEFORE the cache is read — no stale prefill."""
poller = _IntentsPoller(default=NETFLIX_DUMP)
manager = _manager(poller)
first = await manager.async_scan_intents(NETFLIX)
assert first.status == INTENT_SCAN_FRESH and first.candidates
manager.entry.options[CONF_ADB_ENABLED] = False # the user turns ADB off
after = await manager.async_scan_intents(NETFLIX)
assert after.status == INTENT_SCAN_ADB_DISABLED
assert after.candidates == (), "a disabled entry offers no scan answer at all"
assert len(poller.shell_calls) == 1, "only the original, permitted scan ran"
async def test_the_no_poller_control_keeps_its_own_status(self):
manager = _manager(None, adb_enabled=True)
result = await manager.async_scan_intents(NETFLIX)
assert result.status == INTENT_SCAN_ADB_UNAVAILABLE, (
"no poller is not the same fact as the option being off"
)
+30
View File
@@ -1340,3 +1340,33 @@ def test_the_text_helpers_do_not_run_in_a_thread():
def test_asyncio_is_still_needed_by_the_module_under_test():
assert asyncio is not None # keeps the import honest if the tests shrink
def test_a_truncated_capture_prefills_no_url(modules):
"""t18/F2 at the CONSUMPTION end: a cut detail must not become a form URL.
The parser drops an unterminated quoted value (see
tests/test_intents.py::TestATruncatedValueIsNotFabricated); this is the
consequence the review named — `_candidate_data` must not build
``https://www.exam`` out of a host it only half-read.
"""
from nsh.ha_adb_common import parse_activity_resolver_table
capture = (
"Activity Resolver Table:\n"
" Schemes:\n"
" https:\n"
" deca7fa com.netflix.ninja/.MainActivity filter fc1\n"
' Action: "android.intent.action.VIEW"\n'
' Scheme: "ht\n'
' Authority: "www.exam\n'
"Service Resolver Table:\n"
)
[candidate] = parse_activity_resolver_table(capture)
values, _note = modules.flow.intent_prefill_from_candidate("com.netflix.ninja", candidate)
assert values[INTENT_FIELD_DATA] == "", "a cut scheme/authority prefills NO data"
assert "www.exam" not in values[INTENT_FIELD_DATA]
assert values[INTENT_FIELD_ACTION] == "android.intent.action.VIEW"
assert values[INTENT_FIELD_ACTIVITY] == "com.netflix.ninja/.MainActivity"