fix(#73): service single-flight queue + secret-key redaction (S1)

Greens A2's adversarial tests, fixing the Gemini findings:
- BLOCKER #1 (race): a chain-lock (_serialized) serializes every transport
  round-trip, so overlapping refresh()/setFlat() can no longer cross responses
  (firmware has no request id; responses correlate by order).
- MINOR #6 (secret leak): error messages redact secret key names
  (wifi.pwd / *.password / *.pwd -> "<secret>").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/99/head
Strycher 3 months ago
parent 5ba769e90a
commit a23c2eeedc

@ -51,10 +51,29 @@ class ObserverConfigService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
// --- Single-flight serialization (Gemini BLOCKER #1) ----------------------
// No firmware request id -> responses correlate by ORDER, so exactly one
// transport round-trip may be in flight at a time. This chain-lock serializes
// every send/await so overlapping refresh()/setFlat() cannot cross responses.
Future<void> _lock = Future<void>.value();
Future<T> _serialized<T>(Future<T> Function() op) {
final release = Completer<void>();
final prev = _lock;
_lock = release.future;
return prev.then((_) => op()).whenComplete(() => release.complete());
}
/// Secret keys whose name must never appear in a surfaced error (Gemini #6).
static bool _isSecretKey(String key) =>
key == 'wifi.pwd' || key.endsWith('.password') || key.endsWith('.pwd');
static String _safeKey(String key) => _isSecretKey(key) ? '<secret>' : key;
// --- Round-trips ---------------------------------------------------------- // --- Round-trips ----------------------------------------------------------
/// Send [frame] and await the first config response (scalar SET/GET). /// Send [frame] and await the first config response (scalar SET/GET).
Future<ConfigResponse> _roundTrip(Uint8List frame) async { Future<ConfigResponse> _roundTrip(Uint8List frame) {
return _serialized(() async {
final completer = Completer<ConfigResponse>(); final completer = Completer<ConfigResponse>();
final sub = _connector.receivedFrames.listen((f) { final sub = _connector.receivedFrames.listen((f) {
if (f.isNotEmpty && if (f.isNotEmpty &&
@ -69,6 +88,7 @@ class ObserverConfigService extends ChangeNotifier {
} finally { } finally {
await sub.cancel(); await sub.cancel();
} }
});
} }
/// SET a flat key (or `mqtt.broker.<N>.<field>`). Returns true on ACK; on ERR /// SET a flat key (or `mqtt.broker.<N>.<field>`). Returns true on ACK; on ERR
@ -80,10 +100,12 @@ class ObserverConfigService extends ChangeNotifier {
_setError(null); _setError(null);
return true; return true;
} }
_setError(r is ConfigErr ? r.text : 'SET $key: unexpected response'); _setError(
r is ConfigErr ? r.text : 'SET ${_safeKey(key)}: unexpected response',
);
return false; return false;
} catch (e) { } catch (e) {
_setError('SET $key failed: $e'); _setError('SET ${_safeKey(key)} failed: $e');
return false; return false;
} }
} }
@ -96,7 +118,7 @@ class ObserverConfigService extends ChangeNotifier {
if (r is ConfigErr) _setError(r.text); if (r is ConfigErr) _setError(r.text);
return null; return null;
} catch (e) { } catch (e) {
_setError('GET $key failed: $e'); _setError('GET ${_safeKey(key)} failed: $e');
return null; return null;
} }
} }
@ -106,7 +128,8 @@ class ObserverConfigService extends ChangeNotifier {
setFlat('mqtt.broker.$slot.$field', value); setFlat('mqtt.broker.$slot.$field', value);
/// Read the broker pool (paginated START → KV → END). Null on timeout. /// Read the broker pool (paginated START → KV → END). Null on timeout.
Future<List<BrokerConfig>?> getBrokers() async { Future<List<BrokerConfig>?> getBrokers() {
return _serialized(() async {
final decoder = BrokerListDecoder(); final decoder = BrokerListDecoder();
final completer = Completer<List<BrokerConfig>>(); final completer = Completer<List<BrokerConfig>>();
final sub = _connector.receivedFrames.listen((f) { final sub = _connector.receivedFrames.listen((f) {
@ -123,6 +146,7 @@ class ObserverConfigService extends ChangeNotifier {
} finally { } finally {
await sub.cancel(); await sub.cancel();
} }
});
} }
// --- Full snapshot -------------------------------------------------------- // --- Full snapshot --------------------------------------------------------

Loading…
Cancel
Save

Powered by TurnKey Linux.