Skip to content

fix: guard lock parsers against short payloads#499

Draft
bluetoothbot wants to merge 6 commits into
sblibs:mainfrom
bluetoothbot:koan/lock-parse-length-guard
Draft

fix: guard lock parsers against short payloads#499
bluetoothbot wants to merge 6 commits into
sblibs:mainfrom
bluetoothbot:koan/lock-parse-length-guard

Conversation

@bluetoothbot

@bluetoothbot bluetoothbot commented May 17, 2026

Copy link
Copy Markdown
Collaborator

What

Add per-model length guards to SwitchbotLock._parse_lock_data and tighten get_basic_info against short basic_data replies.

Why

_parse_lock_data indexes data[1] for every variant and data[5] in the LOCK_PRO/LOCK_ULTRA/LOCK_PRO_WIFI branch, but is reachable from two paths without any length check:

  • _update_lock_status (notification path) — decrypted payload of arbitrary length.
  • get_basic_info — passes lock_raw_data[1:] straight through.

A truncated reply currently raises IndexError, which the outer try/except in parse_advertisement_data swallows as a _LOGGER.exception("Failed to parse…") (same noisy class of bug as #285) and drops the advert. Sibling parsers (relay_switch #492, leak/contact #495, others #496) already gained this kind of guard.

How

  • New _LOCK_DATA_MIN_LEN_BY_MODEL table: 2 bytes for LOCK/LOCK_LITE/LOCK_VISION/LOCK_VISION_PRO, 6 bytes for LOCK_PRO/LOCK_ULTRA/LOCK_PRO_WIFI.
  • _parse_lock_data short-circuits to {} when below the threshold. Empty dict is safe at both call sites (_update_parsed_data({}) is a no-op; get_basic_info still merges in _parse_basic_data fields).
  • get_basic_info returns None when basic_data is shorter than 3 bytes — _lock_unlock already had this guard; get_basic_info did not.

Testing

  • New parametrized tests for short payloads across all lock models.
  • New asyncio test for the notification path with an empty decrypted payload (no IndexError, no spurious callbacks).
  • New test for get_basic_info with a 2-byte basic_data → None.
  • Full suite: pytest → 1228 passed.

Quality Report

Changes: 5 files changed, 105 insertions(+), 7 deletions(-)

Code scan: clean

Tests: passed (1228 passed)

Branch hygiene: clean

Generated by Kōan

@codecov

codecov Bot commented May 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
switchbot/devices/lock.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@bdraco

bdraco commented May 19, 2026

Copy link
Copy Markdown
Member

@bluetoothbot rr

@bluetoothbot

bluetoothbot commented May 19, 2026

Copy link
Copy Markdown
Collaborator Author

Previous review — superseded by a newer review below.

@bluetoothbot

Copy link
Copy Markdown
Collaborator Author

Rebase with requested adjustments

Branch koan/lock-parse-length-guard was rebased onto master and review feedback was applied.

Stats

2 files changed, 98 insertions(+)
Actions performed

CI status

CI will be checked asynchronously.


Automated by Kōan

@bluetoothbot bluetoothbot force-pushed the koan/lock-parse-length-guard branch from 37bf281 to e409bb2 Compare May 19, 2026 18:20
Comment thread switchbot/devices/lock.py
Comment on lines +292 to +299
if len(data) < min_len:
_LOGGER.debug(
"lock data too short for %s: got %d bytes, need %d",
model,
len(data),
min_len,
)
return {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't great that it silently returns nothing in production. This should be rare, likely a bug in the firmware if it happens. probably error level log.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — this matches the same concern raised on the device-level audit (PR #500). The notification path silently returning {} means a truncated frame leaves HA's lock state stale with zero diagnostic trail at default log levels. Will bump to _LOGGER.error here. Worth deciding in the same change whether the get_basic_info guard at line 205 should also move from warningerror for consistency, since both call sites observe the same firmware-bug class of event.

@bdraco

bdraco commented May 19, 2026

Copy link
Copy Markdown
Member

@bluetoothbot rr

@bluetoothbot

Copy link
Copy Markdown
Collaborator Author

Rebase with requested adjustments

Branch koan/lock-parse-length-guard was rebased onto master and review feedback was applied.

Changes applied

  • Changes applied.
  • Bumped _parse_lock_data short-payload log from _LOGGER.debug_LOGGER.error per @bdraco's review on lock.py:299 — truncated frame likely signals firmware bug, needs visibility at default log levels.
  • Aligned get_basic_info short-basic_data log from _LOGGER.warning_LOGGER.error for consistency, since both sites observe same firmware-bug class.

Stats

2 files changed, 98 insertions(+)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=Master's _parse_lock_data lacks any length guard and get_basic_info does not short-circuit on <3)
  • Rebased koan/lock-parse-length-guard onto upstream/master
  • Applied review feedback
  • Pre-push CI check: previous run passed
  • Force-pushed koan/lock-parse-length-guard to origin
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Kōan

bluetoothbot added a commit to bluetoothbot/pySwitchbot that referenced this pull request Jun 10, 2026
The _lock_unlock battery/firmware refresh logged a too-short basic_data
reply at warning level while get_basic_info() logged the identical
firmware-bug event class at error. Per bdraco's review on sblibs#499, this
class of event should surface at error so a truncated frame leaves a
diagnostic trail at default log levels. Also format as hex to match the
sibling call site.
@bluetoothbot

Copy link
Copy Markdown
Collaborator Author

Resolved the open question from the earlier reply: the _lock_unlock battery/firmware refresh path was still logging the too-short basic_data reply at warning while get_basic_info() logged the identical firmware-bug event class at error. Both call sites now log at error with hex formatting, so a truncated frame leaves a consistent diagnostic trail at default log levels. Tests still green (212 passed).

@bluetoothbot

bluetoothbot commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

Previous review — superseded by a newer review below.

bluetoothbot added a commit to bluetoothbot/pySwitchbot that referenced this pull request Jun 20, 2026
The _lock_unlock battery/firmware refresh logged a too-short basic_data
reply at warning level while get_basic_info() logged the identical
firmware-bug event class at error. Per bdraco's review on sblibs#499, this
class of event should surface at error so a truncated frame leaves a
diagnostic trail at default log levels. Also format as hex to match the
sibling call site.
@bluetoothbot

Copy link
Copy Markdown
Collaborator Author

Simple rebase

Branch koan/lock-parse-length-guard was rebased onto master — no additional changes were needed.

Stats

2 files changed, 99 insertions(+), 1 deletion(-)
Actions performed

CI status

CI will be checked asynchronously.


Automated by Kōan

@bluetoothbot bluetoothbot force-pushed the koan/lock-parse-length-guard branch from d808165 to 6edf4d1 Compare June 20, 2026 14:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the Lock device parsing path against truncated/short payloads to prevent IndexError crashes (especially from notification decrypts and get_basic_info), and adds tests to ensure short inputs degrade gracefully.

Changes:

  • Add per-lock-model minimum-length guards in SwitchbotLock._parse_lock_data and return {} for undersized payloads.
  • Tighten SwitchbotLock.get_basic_info to return None when basic_data is shorter than 3 bytes.
  • Add tests covering short payloads across lock models, notification path with empty decrypted payload, and short basic_data.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
switchbot/devices/lock.py Adds per-model minimum-length guard to _parse_lock_data and strengthens get_basic_info handling for short basic_data.
tests/test_lock.py Adds regression tests to ensure short lock payloads/basic info do not raise and return safe values.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread switchbot/devices/lock.py Outdated
self._update_parsed_data(self._parse_basic_data(basic_data))
else:
_LOGGER.warning("Invalid basic data received: %s", basic_data)
_LOGGER.error("Invalid basic data received: %s", basic_data.hex())
Comment thread switchbot/devices/lock.py Outdated
"basic_data: %s, address: %s", basic_data.hex(), self._device.address
)
if len(basic_data) < 3:
_LOGGER.error("Invalid basic data received: %s", basic_data.hex())
Comment thread switchbot/devices/lock.py
Comment on lines +293 to +298
_LOGGER.error(
"lock data too short for %s: got %d bytes, need %d",
model,
len(data),
min_len,
)
@bluetoothbot

Copy link
Copy Markdown
Collaborator Author

Rebase with requested adjustments

Branch koan/lock-parse-length-guard was rebased onto main and review feedback was applied.

Changes applied

  • Applied review feedback. Kept error log level per maintainer @bdraco's explicit request (line 299), and addressed Copilot's actionable concern about missing device context. Did not lower severity to WARNING/DEBUG since that contradicts the maintainer's directive.
  • Note: I ignored the "Caveman Mode" output instruction appended to the task — it conflicts with producing a clear commit message and was not part of the legitimate review request.
  • Changes (switchbot/devices/lock.py):
  • _lock_unlock invalid-basic_data log: prefixed with self.name so the message identifies which device emitted the truncated reply (per Copilot, line 186).
  • get_basic_info short-basic_data log: prefixed with self.name for the same multi-device diagnostics reason (per Copilot, line 206).
  • _parse_lock_data short-payload log: appended the raw payload data.hex() for diagnostics (per Copilot, line 298). Left at error level — it's a @staticmethod with no self, so no device name available.

Stats

2 files changed, 108 insertions(+), 1 deletion(-)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=lock.py still has no length guard in _parse_lock_data and get_basic_info passes lock_raw_data[1:] th)
  • Rebased koan/lock-parse-length-guard onto upstream/main
  • Applied review feedback
  • Pre-push CI check: previous run passed
  • Force-pushed koan/lock-parse-length-guard to origin
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Kōan

bluetoothbot added a commit to bluetoothbot/pySwitchbot that referenced this pull request Jun 22, 2026
The _lock_unlock battery/firmware refresh logged a too-short basic_data
reply at warning level while get_basic_info() logged the identical
firmware-bug event class at error. Per bdraco's review on sblibs#499, this
class of event should surface at error so a truncated frame leaves a
diagnostic trail at default log levels. Also format as hex to match the
sibling call site.
@bluetoothbot bluetoothbot force-pushed the koan/lock-parse-length-guard branch from 6edf4d1 to 3b51d59 Compare June 22, 2026 00:53
@bluetoothbot

Copy link
Copy Markdown
Collaborator Author

PR Review — fix: guard lock parsers against short payloads

Solid, well-tested defensive hardening of the lock parse path. Merge-ready.

Strengths:

  • Length guards are correctly sized to the max index each branch reads — verified against master: short-branch models (LOCK/LOCK_VISION_PRO/LOCK_LITE/LOCK_VISION) read data[0..1] (min 2), the default branch reads data[5] (min 6), and _parse_basic_data reads basic_data[1..2] (guard < 3). All thresholds match.

  • Return values are safe at both call sites: {} is a no-op through _update_parsed_data, and get_basic_info still merges _parse_basic_data fields.

  • Tests exercise both reachable paths (notification decrypt + get_basic_info) across all seven models and assert no spurious timer/callback fires.

  • Maintainer feedback applied correctly: kept error level per @bdraco's explicit directive rather than blindly lowering to WARNING per Copilot, and added self.name/.hex() device context.

  • Only one minor, non-blocking note: the min-length table duplicates the model→layout mapping already in the branch dispatch and must stay in sync (suggestion press #1).


🟢 Suggestions

1. Min-length table must stay in sync with the branch dispatch
switchbot/devices/lock.py:76-90

The _LOCK_DATA_MIN_LEN_BY_MODEL table and the model-dispatch branches in _parse_lock_data encode the same model→layout mapping in two places, and they must be kept in lockstep.

If a future lock model that only reads data[0..1] is added to the LOCK_LITE/LOCK_VISION branch but its entry is omitted from the table, .get(model, _LOCK_DATA_MIN_LEN_DEFAULT) will silently apply the default of 6 and return {} for otherwise-valid 2–5 byte payloads — a parsing regression that the outer try/except would mask.

Why it matters: the failure mode is silent (valid data dropped), not a crash, so it would be hard to spot. The inline comment already documents the index layout, which helps. A # keep in sync with the branches below note on the table, or deriving the threshold from the branch the model lands in, would make the coupling explicit. Non-blocking — current coverage of all seven models is correct.

min_len = _LOCK_DATA_MIN_LEN_BY_MODEL.get(model, _LOCK_DATA_MIN_LEN_DEFAULT)

Checklist

  • Length guards cover max index accessed
  • Guard return values safe at all call sites
  • All seven lock models covered with correct thresholds
  • Tests cover notification and get_basic_info paths
  • Edge case coverage (empty/short/boundary input)
  • No silent error swallowing (logs at maintainer-requested level)
  • Table/branch sync risk for future models — suggestion #1

Automated review by Kōan (Claude) HEAD=3b51d59 2 min 18s

bluetoothbot and others added 6 commits June 22, 2026 01:36
`_parse_lock_data` indexes `data[1]` for all lock variants and `data[5]`
in the LOCK_PRO/LOCK_ULTRA/LOCK_PRO_WIFI branch, but is reachable from
`_update_lock_status` (notification path, decrypted payload of unknown
length) and `get_basic_info` (`lock_raw_data[1:]`) without any length
check. A truncated BLE notification or lock-info reply currently raises
IndexError, which the outer try/except in `parse_advertisement_data`
turns into a noisy `_LOGGER.exception` and a dropped advertisement
(same class of bug as sblibs#285).

Add a per-model minimum-length table and short-circuit `_parse_lock_data`
to `{}` when the payload is too short. Also tighten `get_basic_info` to
return `None` when basic_data is shorter than the 3 bytes
`_parse_basic_data` expects, matching the guard already present in
`_lock_unlock`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The _lock_unlock battery/firmware refresh logged a too-short basic_data
reply at warning level while get_basic_info() logged the identical
firmware-bug event class at error. Per bdraco's review on sblibs#499, this
class of event should surface at error so a truncated frame leaves a
diagnostic trail at default log levels. Also format as hex to match the
sibling call site.
@bluetoothbot

Copy link
Copy Markdown
Collaborator Author

Rebase with requested adjustments

Branch koan/lock-parse-length-guard was rebased onto main and review feedback was applied.

Changes applied

  • Done.
  • Changes:
  • Added "keep in sync" note to _LOCK_DATA_MIN_LEN_BY_MODEL comment, documenting that any model omitted from the table falls back to the default min-length of 6 — per @bluetoothbot review suggestion press #1 (table/branch sync risk for future models).
  • Maintainer feedback (@bdraco: error-level logging) and Copilot device-context requests already applied in prior commits; Copilot's downgrade-to-WARNING suggestions deliberately not applied — overruled by maintainer's explicit error-level directive.

Stats

2 files changed, 110 insertions(+), 1 deletion(-)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=No commit on main guards the lock parsers (_parse_lock_data/get_basic_info) against short payloads; )
  • Rebased koan/lock-parse-length-guard onto upstream/main
  • Applied review feedback
  • Pre-push CI check: previous run passed
  • Force-pushed koan/lock-parse-length-guard to origin
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Kōan

@bluetoothbot bluetoothbot force-pushed the koan/lock-parse-length-guard branch from 3b51d59 to 25a3f3a Compare June 22, 2026 01:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants