---
phase: 260726-42c-create-a-script-that-enumerates-mac-addr
reviewed: 2026-07-26T00:00:00Z
depth: quick
files_reviewed: 1
files_reviewed_list:
  - scripts/serial_mac_audit.py
findings:
  critical: 0
  warning: 4
  info: 4
  total: 8
status: issues_found
---

# Quick Task 260726-42c: Code Review Report

**Reviewed:** 2026-07-26
**Depth:** quick (single file, correctness-focused)
**Files Reviewed:** 1
**Status:** issues_found

## Summary

Reviewed `scripts/serial_mac_audit.py` (standalone PEP 723 diagnostic) with focus
on silent-wrong-data risks. The core correlation machinery is sound and was
verified against the library source:

- **MAC normalisation** — `normalise_mac()` zero-pads and lowercases both sides;
  `Device.get_mac_address()` (src/lifx/devices/base.py:681) already emits padded
  lowercase, and every ARP parser normalises before storing. No unpadded-octet
  mismatch path exists. Verified: `uv run scripts/serial_mac_audit.py --self-test`
  passes, including the verbatim macOS fixture with unpadded octets.
- **ARP parsing** — `(incomplete)` entries fail the `[0-9a-fA-F:]+` group and are
  dropped; `_is_valid_mac` enforces exactly six 1–2 hex-digit octets.
- **Verdict boundaries** — identical / +1 mod 256 (including 0xff→0x00 wraparound,
  asserted in self-test) / mismatch / unknown all correct; wraparound matches the
  library's `(octets[5] + 1) % 256` rule at base.py:679.
- **`--csv` purity** — `write_csv()` is the only stdout writer in CSV mode; all
  Rich output (progress, warnings, summary line) goes through `Console(stderr=True)`.
- **subprocess** — fixed argv lists, no shell, output through strict parsers. No
  injection surface. (But see WR-02/WR-03 for robustness gaps.)
- **PEP 723 pin** — `[tool.uv.sources]` pins the local editable checkout via
  `path = "../"` (relative to the script directory = repo root), and the self-test
  asserts `lifx.__file__` resolves inside the checkout.
- **Duplicate devices** — `discover()` dedupes by serial in the library
  (src/lifx/network/discovery.py:429), so no double-counted rows.
- **Australian English** — compliant ("normalise", "colour-coded"); no
  American-spelling hits.

Four warnings concern per-device isolation and ARP-table robustness; none produce
wrong verdicts on the happy path, but two can silently lose or skew data.

## Warnings

### WR-01: Row is lost if `connection.close()` raises in `_probe`'s finally block

**File:** `scripts/serial_mac_audit.py:237-240`
**Issue:** The `finally` block runs `await device.connection.close()` *before*
`rows.append(row)` and `progress.advance(task_id)`. If `close()` raises (socket
already torn down, transport error), the exception propagates out of the
`finally` block, the row is never appended, and `asyncio.gather(...,
return_exceptions=True)` swallows the error. The device silently vanishes from
the dataset — exactly the "one device skews the sweep" failure the docstring
claims to prevent.
**Fix:**
```python
finally:
    rows.append(row)
    progress.advance(task_id)
    try:
        await device.connection.close()
    except Exception:  # close failure must not affect the dataset
        pass
```

### WR-02: `subprocess.run` for `arp`/`ip` has no timeout and no FileNotFoundError handling — crashes after the sweep, losing all data

**File:** `scripts/serial_mac_audit.py:275-287`
**Issue:** Two robustness gaps in `read_arp_table()`:
1. No `timeout=` — a hung `arp`/`ip` invocation blocks forever.
2. No `FileNotFoundError` handling — if the binary is not on PATH (`ip` lives in
   `/usr/sbin`, historically absent from non-root PATH on Debian; `arp` likewise
   under restricted environments), the script raises a traceback *after* the
   entire fleet sweep has completed and *before* any CSV/table is written. All
   collected data is lost.
**Fix:**
```python
try:
    result = subprocess.run(  # nosec B603 B607
        ["arp", "-an"], capture_output=True, text=True, check=False, timeout=10.0
    )
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
    err_console.print(f"[yellow]ARP lookup failed ({exc}) — verdicts unknown.[/yellow]")
    return {}
```
(Same pattern for the `ip -4 neigh show` branch.)

### WR-03: Failed `arp` invocation silently yields an all-unknown dataset

**File:** `scripts/serial_mac_audit.py:275-287`
**Issue:** `check=False` with `result.stdout` consumed unconditionally: if `arp`
exits non-zero (or emits nothing), the map is empty and every verdict becomes
`unknown` with no diagnostic — indistinguishable from "no ARP entries existed".
Only the unsupported-platform branch warns. For a data-accuracy tool, a
degraded-to-useless run must announce itself.
**Fix:** After parsing, warn on stderr when `result.returncode != 0` or the
parsed map is empty despite `rows` being non-empty:
```python
if result.returncode != 0:
    err_console.print(
        f"[yellow]arp exited {result.returncode}: {result.stderr.strip()}[/yellow]"
    )
```

### WR-04: First-wins on duplicate ARP IPs can silently record the wrong real MAC

**File:** `scripts/serial_mac_audit.py:138` (also 156, 194)
**Issue:** `entries.setdefault(ip, mac)` keeps the first parseable entry when an
IP appears on multiple interfaces. The fixture's duplicates share one MAC, but a
stale ARP entry (DHCP lease reassigned, device moved between VLAN-bridged
interfaces) can leave the *same IP with two different MACs*. First-wins then
silently selects an arbitrary one, producing a wrong `real_mac` and a wrong
verdict — the worst failure mode for this script, since a spurious "mismatch" or
"identical" row corrupts the correlation dataset it exists to build.
**Fix:** Detect conflicts and discard the IP (verdict degrades to `unknown`
rather than being wrong):
```python
normalised = normalise_mac(match.group(2))
existing = entries.get(match.group(1))
if existing is not None and existing != normalised:
    conflicted.add(match.group(1))  # drop from entries at end, warn on stderr
    continue
entries.setdefault(match.group(1), normalised)
```

## Info

### IN-01: A −1 serial/MAC relationship is lumped into "mismatch"

**File:** `scripts/serial_mac_audit.py:160-180`
**Issue:** `classify_verdict` only recognises +1 mod 256. If any firmware
generation derives serial = MAC + 1 (i.e. real MAC = serial − 1), those rows
read as generic "mismatch", discarding exactly the signal this audit exists to
capture when refining the rule.
**Fix:** Add an `off-by-minus-one` verdict for `real_octets[5] == (serial_octets[5] - 1) % 256`
with the first five octets equal.

### IN-02: Probe failure suppresses classification even when serial + real MAC are both known

**File:** `scripts/serial_mac_audit.py:444-446`
**Issue:** `if row.derived_mac:` gates `classify_verdict`, but classification
needs only the serial (always known from discovery) and the ARP MAC. A device
that answered discovery but failed the two probes keeps verdict `unknown`
despite a classifiable observation. The row lacks firmware for the summary
grouping, so this is defensible — but the per-row verdict column discards data.
**Fix:** Classify whenever `real_mac is not None`; the summary panel already
groups such rows under the blank-firmware bucket.

### IN-03: Exception text interpolated into Rich markup

**File:** `scripts/serial_mac_audit.py:234-236`
**Issue:** `f"[dim]{device.serial} ({device.ip}) did not answer: {exc}[/dim]"` —
an exception message containing `[` sequences is interpreted as Rich markup and
can garble or drop stderr diagnostics.
**Fix:** Escape the message: `from rich.markup import escape` →
`escape(str(exc))`, or pass `markup=False` and drop the dim styling.

### IN-04: Only `LifxError` produces a stderr diagnostic in `_probe`

**File:** `scripts/serial_mac_audit.py:233-236`
**Issue:** A non-`LifxError` exception (e.g. `OSError` from a vanished
interface) still yields an `unknown` row via `finally`, but is swallowed
silently by `gather(return_exceptions=True)` with no stderr message — the
operator cannot tell why the row is blank.
**Fix:** Add a broad `except Exception as exc:` arm that logs to `err_console`
the same way (after WR-01's reordering, nothing else depends on propagation).

---

_Reviewed: 2026-07-26_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: quick_
