fix: bound and gate the crop evidence lane

The crop lane asked the phone for a full-resolution crop of a facing the
frame could not decide. Four defects made that unsafe or useless:

* A capability was half-applied. `crop: true` without a usable
  `max_crop_bytes` enabled the lane on the client's say-so, leaving the two
  sides believing different limits. A capability is now believed whole or
  refused with BAD_MESSAGE, and the limit must be an integer in 1..1048576
  (bool is an int in Python and is not a limit).
* A small JPEG could claim enormous dimensions. The header is now read
  without decoding and anything over 4 megapixels is refused before the
  decode allocates it.
* A request was single-use, so an expired one stranded a facing only the
  crop lane could decide. The lane now spends the same max_attempts budget
  the frame path spends, refilled on method change and on settled-label
  drift, and a phone that never answers still cannot loop.
* A candidate could be asked about from a box the frame path would refuse:
  clipped by the frame edge, carried over from an earlier frame, or measured
  at a score the tracker does not trust. Both lanes now share one edge gate,
  and only a track whose box this frame actually measured is asked about.

Drift and method change also cleared `identity` but left `confirmed = True`,
which permanently blocked both the crop lane and label inheritance for that
track; both now clear it, without which the budget reset was dead code.

Blur and glare are refused as whole-capture faults, while `too_far` is
exempt on purpose: a distant shelf is the one frame a native-resolution crop
exists to decide.
This commit is contained in:
kiyo9w 2026-09-28 17:22:52 +09:00
parent 02e69acac7
commit aa25f99699
5 changed files with 504 additions and 46 deletions

View file

@ -366,8 +366,35 @@ class WebSocketProtocolTests(unittest.TestCase):
self.assertEqual(ack["type"], "capabilities_ack")
self.assertEqual(ack["capabilities"], {"crop": True, "max_crop_bytes": 65536})
def test_a_capability_the_server_cannot_honour_is_refused(self):
"""A half-stated or malformed capability turns nothing on."""
for message in (
{"type": "capabilities", "max_crop_bytes": 65536},
{"type": "capabilities", "crop": False, "max_crop_bytes": 65536},
{"type": "capabilities", "crop": "true", "max_crop_bytes": 65536},
{"type": "capabilities", "crop": True},
{"type": "capabilities", "crop": True, "max_crop_bytes": 0},
{"type": "capabilities", "crop": True, "max_crop_bytes": -4096},
{"type": "capabilities", "crop": True, "max_crop_bytes": True},
{"type": "capabilities", "crop": True, "max_crop_bytes": "65536"},
{"type": "capabilities", "crop": True, "max_crop_bytes": 64 * 1024 * 1024},
):
with self.subTest(capability=message):
with self.crop_app().websocket_connect("/v1/stream") as socket:
socket.receive_json() # hello
socket.send_json(message)
self.assertEqual(socket.receive_json()["code"], "BAD_MESSAGE")
# A refused capability leaves this session on protocol v1: the
# facing it cannot report is never asked about.
for frame_id in (1, 2, 3):
socket.send_bytes(frame_payload(frame_id))
result = socket.receive_json()
self.assertEqual(result["stats"]["crops"]["requested"], 0)
for track in result["tracks"]:
self.assertNotIn("crop_request", track)
def test_a_v1_client_is_never_asked_for_a_crop(self):
with self.client.websocket_connect("/v1/stream") as socket:
with self.crop_app().websocket_connect("/v1/stream") as socket:
socket.receive_json() # hello
for frame_id in (1, 2, 3):
socket.send_bytes(frame_payload(frame_id))
@ -379,7 +406,7 @@ class WebSocketProtocolTests(unittest.TestCase):
def test_a_crop_round_trip_labels_the_track(self):
with self.crop_app().websocket_connect("/v1/stream") as socket:
socket.receive_json() # hello
socket.send_json({"type": "capabilities", "crop": True})
socket.send_json({"type": "capabilities", "crop": True, "max_crop_bytes": 131072})
self.assertEqual(socket.receive_json()["type"], "capabilities_ack")
socket.send_bytes(frame_payload(1))
socket.receive_json()
@ -406,7 +433,7 @@ class WebSocketProtocolTests(unittest.TestCase):
def test_a_crop_that_does_not_match_its_request_is_refused(self):
with self.crop_app().websocket_connect("/v1/stream") as socket:
socket.receive_json() # hello
socket.send_json({"type": "capabilities", "crop": True})
socket.send_json({"type": "capabilities", "crop": True, "max_crop_bytes": 131072})
socket.receive_json()
socket.send_bytes(frame_payload(1))
socket.receive_json()
@ -429,7 +456,7 @@ class WebSocketProtocolTests(unittest.TestCase):
def test_a_crop_that_is_not_an_image_is_refused(self):
with self.crop_app().websocket_connect("/v1/stream") as socket:
socket.receive_json() # hello
socket.send_json({"type": "capabilities", "crop": True})
socket.send_json({"type": "capabilities", "crop": True, "max_crop_bytes": 131072})
socket.receive_json()
socket.send_bytes(frame_payload(1))
socket.receive_json()
@ -445,6 +472,28 @@ class WebSocketProtocolTests(unittest.TestCase):
})
self.assertEqual(socket.receive_json()["code"], "CROP_UNDECODABLE")
def test_a_small_jpeg_claiming_huge_pixels_is_refused_before_decode(self):
with self.crop_app().websocket_connect("/v1/stream") as socket:
socket.receive_json()
socket.send_json({"type": "capabilities", "crop": True, "max_crop_bytes": 131072})
socket.receive_json()
socket.send_bytes(frame_payload(1))
socket.receive_json()
socket.send_bytes(frame_payload(2))
request = socket.receive_json()["tracks"][0]["crop_request"]
jpeg = bytearray(jpeg_bytes(80, 120))
sof = jpeg.index(b"\xff\xc0")
jpeg[sof + 5:sof + 9] = (16384).to_bytes(2, "big") * 2
socket.send_json({
"type": "crop", "request_id": request["request_id"],
"track_id": request["track_id"], "frame_id": request["frame_id"],
"jpeg": base64.b64encode(jpeg).decode("ascii"),
})
refused = socket.receive_json()
self.assertEqual(refused["code"], "CROP_TOO_LARGE")
socket.send_json({"type": "ping", "t": 1})
self.assertEqual(socket.receive_json(), {"type": "pong", "t": 1})
def test_a_crop_without_an_image_is_refused(self):
with self.client.websocket_connect("/v1/stream") as socket:
socket.receive_json() # hello