Skip to main content

futu_rest/
strict_fields.rs

1//! v1.4.93 P0-2 (BUG-002): REST unknown-field validation for strict POST routes.
2//!
3//! ## Problem
4//!
5//! REST endpoint typo fields (e.g. `xyzzy_bogus` / `begin_timme`) can otherwise
6//! be silently accepted by generated proto JSON structs.
7//!
8//! Root cause (CLAUDE.md pitfall #30): proto-build attaches `#[serde(default)]`
9//! globally to all messages -> serde silently drops unknown fields. typo doesn't
10//! 400, daemon executes with default zero values, returns ret_type=0 + empty data
11//! (silent-success anti-pattern, pitfall #45).
12//!
13//! ## Fix
14//!
15//! Axum middleware that intercepts request body for strict validator registry paths,
16//! deserializes to typed Request struct, re-serializes to canonical JSON, and
17//! recursively walks both Values to detect any keys in user input not in the
18//! re-serialized typed shape. Unknown -> 400 BAD_REQUEST with explanatory hint.
19//!
20//! The contract source is the strict validator registry below. Regression tests
21//! require all `EndpointSpec`-declared POST routes registered by REST server code
22//! to appear in this registry. We intentionally keep validation in the adapter
23//! layer instead of changing generated prost structs globally, because that would
24//! alter every generated message's JSON acceptance semantics at once.
25//!
26//! ## Limitations
27//!
28//! - Vec/repeated fields cannot be schema-validated for inner keys when default
29//!   instantiated (default Vec is empty). Top-level + first-level nested object
30//!   typos (the BUG-002 case) ARE caught.
31//! - Validation runs AFTER `normalize_json_keys_snake_case` /
32//!   `apply_known_field_aliases` (replicated here to mimic adapter pre-processing)
33//!   so camelCase/aliased names don't false-trigger.
34
35use axum::Json;
36use axum::body::Body;
37use axum::extract::Request;
38use axum::http::StatusCode;
39use axum::middleware::Next;
40use axum::response::{IntoResponse, Response};
41use prost::Message;
42use serde::de::DeserializeOwned;
43use serde_json::Value;
44
45use crate::adapter::{
46    apply_known_field_aliases, expand_symbol_shorthand, maybe_expand_flat_trd_header,
47    maybe_wrap_flat_body_as_c2s, normalize_json_keys_snake_case,
48};
49
50type StrictValidator = fn(&Value) -> Result<(), Vec<String>>;
51
52// v1.4.104 codex round 2 F2 + P1-001 follow-up: list-style endpoints
53// (security_list 主字段) 让 adapter 的 single-symbol shorthand path 生成
54// c2s.security + c2s.owner orphan objects. 这些是 adapter 生成的副产品,
55// 不应当按用户 typo 拒绝。
56const LIST_STYLE_IGNORE: &[&str] = &["c2s.security", "c2s.owner"];
57
58fn strict_unknown_fields_error_body(path: &str, unknown_paths: Vec<String>) -> Value {
59    let message = format!(
60        "REST {} rejects unknown field(s): {}. Check for typos. \
61         v1.4.93 BUG-002 strict validation.",
62        path,
63        unknown_paths.join(", ")
64    );
65    serde_json::json!({
66        "ret_type": -1,
67        "ret_msg": message,
68        "error": message,
69        "unknown_fields": unknown_paths,
70    })
71}
72
73fn validate_qot_sub_list_style(value: &Value) -> Result<(), Vec<String>> {
74    validate_for_path_with_ignore::<futu_proto::qot_sub::Request>(value, LIST_STYLE_IGNORE)
75}
76
77fn validate_basic_qot_list_style(value: &Value) -> Result<(), Vec<String>> {
78    validate_for_path_with_ignore::<futu_proto::qot_get_basic_qot::Request>(
79        value,
80        LIST_STYLE_IGNORE,
81    )
82}
83
84fn validate_snapshot_list_style(value: &Value) -> Result<(), Vec<String>> {
85    validate_for_path_with_ignore::<futu_proto::qot_get_security_snapshot::Request>(
86        value,
87        LIST_STYLE_IGNORE,
88    )
89}
90
91fn validate_static_info_list_style(value: &Value) -> Result<(), Vec<String>> {
92    validate_for_path_with_ignore::<futu_proto::qot_get_static_info::Request>(
93        value,
94        LIST_STYLE_IGNORE,
95    )
96}
97
98pub fn validate_flow_summary_strict(user_value: &Value) -> Result<(), Vec<String>> {
99    validate_for_path_with_ignore::<futu_proto::trd_flow_summary::Request>(
100        user_value,
101        &["c2s.begin_date", "c2s.end_date"],
102    )
103}
104
105/// Return the strict unknown-field validator for a REST path.
106///
107/// v1.4.110 surface-spec runtime enforcement: this replaces the old parallel
108/// `STRICT_PATHS` list. `is_strict_path` and the middleware now share the same
109/// registry, while tests assert every `EndpointSpec` REST POST route is covered.
110fn strict_validator_for_path(path: &str) -> Option<StrictValidator> {
111    match path {
112        // qot endpoints
113        "/api/history-kline" => {
114            Some(validate_for_path::<futu_proto::qot_request_history_kl::Request>)
115        }
116        "/api/subscribe" | "/api/unsubscribe" => Some(validate_qot_sub_list_style),
117        "/api/query-subscription" => {
118            Some(validate_for_path::<futu_proto::qot_get_sub_info::Request>)
119        }
120        "/api/quote" => Some(validate_basic_qot_list_style),
121        "/api/snapshot" => Some(validate_snapshot_list_style),
122        "/api/kline" => Some(validate_for_path::<futu_proto::qot_get_kl::Request>),
123        "/api/orderbook" => Some(validate_for_path::<futu_proto::qot_get_order_book::Request>),
124        "/api/broker" => Some(validate_for_path::<futu_proto::qot_get_broker::Request>),
125        "/api/ticker" => Some(validate_for_path::<futu_proto::qot_get_ticker::Request>),
126        "/api/rt" => Some(validate_for_path::<futu_proto::qot_get_rt::Request>),
127        "/api/static-info" => Some(validate_static_info_list_style),
128        "/api/plate-set" | "/api/list-plates" => {
129            Some(validate_for_path::<futu_proto::qot_get_plate_set::Request>)
130        }
131        "/api/plate-security" => {
132            Some(validate_for_path::<futu_proto::qot_get_plate_security::Request>)
133        }
134        "/api/reference" | "/api/get-reference" => {
135            Some(validate_for_path::<futu_proto::qot_get_reference::Request>)
136        }
137        "/api/owner-plate" => Some(validate_for_path::<futu_proto::qot_get_owner_plate::Request>),
138        "/api/option-chain" => Some(validate_for_path::<futu_proto::qot_get_option_chain::Request>),
139        "/api/warrant" => Some(validate_for_path::<futu_proto::qot_get_warrant::Request>),
140        "/api/capital-flow" => Some(validate_for_path::<futu_proto::qot_get_capital_flow::Request>),
141        "/api/capital-distribution" => {
142            Some(validate_for_path::<futu_proto::qot_get_capital_distribution::Request>)
143        }
144        "/api/user-security" => {
145            Some(validate_for_path::<futu_proto::qot_get_user_security::Request>)
146        }
147        "/api/stock-filter" => Some(validate_for_path::<futu_proto::qot_stock_filter::Request>),
148        "/api/ipo-list" => Some(validate_for_path::<futu_proto::qot_get_ipo_list::Request>),
149        "/api/future-info" => Some(validate_for_path::<futu_proto::qot_get_future_info::Request>),
150        "/api/market-state" => Some(validate_for_path::<futu_proto::qot_get_market_state::Request>),
151        "/api/trading-days" => {
152            Some(validate_for_path::<futu_proto::qot_request_trade_date::Request>)
153        }
154        "/api/rehab" => Some(validate_for_path::<futu_proto::qot_request_rehab::Request>),
155        "/api/suspend" => Some(validate_for_path::<futu_proto::qot_get_suspend::Request>),
156        "/api/history-kl-quota" => {
157            Some(validate_for_path::<futu_proto::qot_request_history_kl_quota::Request>)
158        }
159        "/api/used-quota" => Some(validate_for_path::<futu_proto::used_quota::Request>),
160        "/api/holding-change" => {
161            Some(validate_for_path::<futu_proto::qot_get_holding_change_list::Request>)
162        }
163        "/api/modify-user-security" => {
164            Some(validate_for_path::<futu_proto::qot_modify_user_security::Request>)
165        }
166        "/api/code-change" => Some(validate_for_path::<futu_proto::qot_get_code_change::Request>),
167        "/api/set-price-reminder" => {
168            Some(validate_for_path::<futu_proto::qot_set_price_reminder::Request>)
169        }
170        "/api/price-reminder" => {
171            Some(validate_for_path::<futu_proto::qot_get_price_reminder::Request>)
172        }
173        "/api/option-expiration-date" => {
174            Some(validate_for_path::<futu_proto::qot_get_option_expiration_date::Request>)
175        }
176        "/api/delay-statistics" => {
177            Some(validate_for_path::<futu_proto::get_delay_statistics::Request>)
178        }
179        "/api/token-state" => Some(
180            validate_for_path::<
181                futu_backend::proto_internal::futu_token_state::DaemonGetTokenStateReq,
182            >,
183        ),
184        "/api/risk-free-rate" => Some(
185            validate_for_path::<
186                futu_backend::proto_internal::risk_free_rate::DaemonGetRiskFreeRateReq,
187            >,
188        ),
189        "/api/spread-table" => Some(
190            validate_for_path::<
191                futu_backend::proto_internal::spread_table_6503::DaemonGetSpreadTableReq,
192            >,
193        ),
194        "/api/ticker-statistic" => Some(validate_ticker_statistic_strict),
195        "/api/ticker-statistic-detail" => Some(validate_ticker_statistic_detail_strict),
196
197        // trade endpoints
198        "/api/funds" => Some(validate_for_path::<futu_proto::trd_get_funds::Request>),
199        "/api/positions" => Some(validate_for_path::<futu_proto::trd_get_position_list::Request>),
200        "/api/orders" => Some(validate_for_path::<futu_proto::trd_get_order_list::Request>),
201        "/api/order-fills" => {
202            Some(validate_for_path::<futu_proto::trd_get_order_fill_list::Request>)
203        }
204        "/api/max-trd-qtys" => Some(validate_for_path::<futu_proto::trd_get_max_trd_qtys::Request>),
205        "/api/margin-ratio" => Some(validate_for_path::<futu_proto::trd_get_margin_ratio::Request>),
206        "/api/order" => Some(validate_for_path::<futu_proto::trd_place_order::Request>),
207        "/api/order-fee" => Some(validate_for_path::<futu_proto::trd_get_order_fee::Request>),
208        "/api/history-orders" => {
209            Some(validate_for_path::<futu_proto::trd_get_history_order_list::Request>)
210        }
211        "/api/history-order-fills" => {
212            Some(validate_for_path::<futu_proto::trd_get_history_order_fill_list::Request>)
213        }
214        "/api/sub-acc-push" | "/api/unsub-acc-push" => {
215            Some(validate_for_path::<futu_proto::trd_sub_acc_push::Request>)
216        }
217        "/api/modify-order" | "/api/cancel-all-order" => {
218            Some(validate_for_path::<futu_proto::trd_modify_order::Request>)
219        }
220        "/api/unlock-trade" => Some(validate_for_path::<futu_proto::trd_unlock_trade::Request>),
221        "/api/reconfirm-order" => {
222            Some(validate_for_path::<futu_proto::trd_reconfirm_order::Request>)
223        }
224        "/api/flow-summary" | "/api/acc-cash-flow" => Some(validate_flow_summary_strict),
225        "/api/cash-log" => Some(
226            validate_for_path::<
227                futu_backend::proto_internal::realtime_asset_log::DaemonGetCashLogReq,
228            >,
229        ),
230        "/api/cash-detail" => Some(
231            validate_for_path::<
232                futu_backend::proto_internal::realtime_asset_log::DaemonGetCashDetailReq,
233            >,
234        ),
235        "/api/biz-group" => Some(
236            validate_for_path::<
237                futu_backend::proto_internal::realtime_asset_log::DaemonGetBizGroupReq,
238            >,
239        ),
240        "/api/margin-info" => Some(
241            validate_for_path::<
242                futu_backend::proto_internal::risk_user_account_info::DaemonGetMarginInfoReq,
243            >,
244        ),
245        "/api/account-flag" => Some(
246            validate_for_path::<futu_backend::proto_internal::account_flag::DaemonGetAccountFlagReq>,
247        ),
248        "/api/bond-total-asset" => Some(
249            validate_for_path::<
250                futu_backend::proto_internal::bond_client_view::DaemonGetBondTotalAssetReq,
251            >,
252        ),
253        "/api/bond-single-asset" => Some(
254            validate_for_path::<
255                futu_backend::proto_internal::bond_client_view::DaemonGetBondSingleAssetReq,
256            >,
257        ),
258        "/api/bond-position-list" => Some(
259            validate_for_path::<
260                futu_backend::proto_internal::bond_client_view::DaemonGetBondPositionListReq,
261            >,
262        ),
263        "/api/bond-answer-state" => Some(
264            validate_for_path::<
265                futu_backend::proto_internal::bond_client_view::DaemonGetBondAnswerStateReq,
266            >,
267        ),
268        "/api/bond-trade-reminder" => Some(
269            validate_for_path::<
270                futu_backend::proto_internal::bond_client_view::DaemonGetBondTradeReminderReq,
271            >,
272        ),
273
274        // daemon-local admin POST endpoints.
275        "/api/admin/shutdown" | "/api/admin/reload" => Some(validate_admin_empty_body),
276        _ => None,
277    }
278}
279
280/// Public test helper: returns true iff `path` is in the strict-validation list.
281pub fn is_strict_path(path: &str) -> bool {
282    strict_validator_for_path(path).is_some()
283}
284
285/// Body size cap for body-buffering middleware (10 MiB; same order as proto
286/// max-size guard elsewhere in adapter). Larger bodies bypass strict validation
287/// and fall through to handler — handler still applies its own size limits.
288const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
289
290/// Axum middleware: validate POST body against the typed Request schema for
291/// strict paths. Non-strict paths and non-POST methods pass through unmodified.
292pub async fn strict_field_validation_middleware(req: Request, next: Next) -> Response {
293    if req.method() != axum::http::Method::POST {
294        return next.run(req).await;
295    }
296    let path = req.uri().path().to_owned();
297    let Some(validator) = strict_validator_for_path(path.as_str()) else {
298        return next.run(req).await;
299    };
300
301    let (parts, body) = req.into_parts();
302    let bytes = match axum::body::to_bytes(body, MAX_BODY_BYTES).await {
303        Ok(b) => b,
304        Err(e) => {
305            return (
306                StatusCode::BAD_REQUEST,
307                Json(serde_json::json!({
308                    "error": format!("failed to read request body: {e}")
309                })),
310            )
311                .into_response();
312        }
313    };
314
315    // Empty body: handler will use Req::default(); no fields to validate.
316    if bytes.is_empty() {
317        let req = Request::from_parts(parts, Body::from(bytes));
318        return next.run(req).await;
319    }
320
321    // Parse user input as Value
322    let mut user_value: Value = match serde_json::from_slice(&bytes) {
323        Ok(v) => v,
324        Err(e) => {
325            return (
326                StatusCode::BAD_REQUEST,
327                Json(serde_json::json!({
328                    "error": format!("invalid JSON body: {e}")
329                })),
330            )
331                .into_response();
332        }
333    };
334
335    // v1.4.97 codex audit fix: rename OTP aliases BEFORE strict validation.
336    // Earlier v1.4.96 BUG #008 fix did the rename inside `unlock_trade`
337    // handler, but `/api/unlock-trade` is in the strict validator registry so this middleware
338    // runs first — it would see `otp` / `token` / `one_time_password` as
339    // unknown fields against `trd_unlock_trade::Request` schema and reject
340    // with 400 BEFORE the handler's rename logic can run. Apply rename
341    // pre-validation so user-friendly aliases pass strict validation.
342    // Handler still calls `apply_unlock_trade_otp_aliases` again (idempotent
343    // — sec_otp already present so alias strip is a no-op).
344    if path == "/api/unlock-trade" {
345        crate::routes::trd::apply_unlock_trade_otp_aliases(&mut user_value);
346    }
347
348    let validation_err = validator(&user_value);
349
350    if let Err(unknown_paths) = validation_err {
351        return (
352            StatusCode::BAD_REQUEST,
353            Json(strict_unknown_fields_error_body(&path, unknown_paths)),
354        )
355            .into_response();
356    }
357
358    // Restore body for downstream handler
359    let req = Request::from_parts(parts, Body::from(bytes));
360    next.run(req).await
361}
362
363/// Validate `user_value` against the typed `Req` schema. Returns `Err(unknown_paths)`
364/// if any user keys are not present after a deserialize -> reserialize roundtrip.
365///
366/// Mimics adapter pre-processing: `normalize_json_keys_snake_case` ->
367/// `apply_known_field_aliases` -> `maybe_wrap_flat_body_as_c2s` ->
368/// `maybe_expand_flat_trd_header` -> `expand_symbol_shorthand` -> from_value -> to_value.
369fn validate_for_path<Req>(user_value: &Value) -> Result<(), Vec<String>>
370where
371    Req: Message + Default + DeserializeOwned + serde::Serialize,
372{
373    validate_for_path_with_ignore::<Req>(user_value, &[])
374}
375
376/// Same as `validate_for_path` but tolerates a list of dot-separated paths
377/// (e.g. `["c2s.owner"]`) — these will not be flagged as unknown even if they
378/// appear in `normalized` post-adapter-expansion but are absent from the typed
379/// `Req` shape.
380///
381/// **Use case** (codex F3 fix 2026-04-27): `/api/ticker-statistic` route uses
382/// codex 14th-round Finding 5 (P3, 2026-04-28 02:09): testable extracted
383/// branch for `/api/ticker-statistic` strict validation. Let unit test 调
384/// 真 branch 而不是手写 if/else (pitfall #54 schema-only fix).
385///
386/// 行为: 检查 user-supplied body 是否显式含 owner (4 path):
387/// (a) flat snake_case `{"owner": ...}`
388/// (b) flat PascalCase `{"Owner": ...}` (post-normalize 同 (a))
389/// (c) nested snake_case `{"c2s": {"owner": ...}}`
390/// (d) nested PascalCase `{"c2s": {"Owner": ...}}` (post-normalize 同 (c))
391///
392/// 任一命中 → loud reject `c2s.owner` / `owner`. 否 → 走 generic validator
393/// 容忍 adapter expand 后注入的 `c2s.owner`.
394pub fn validate_ticker_statistic_strict(user_value: &Value) -> Result<(), Vec<String>> {
395    let mut normalized_for_check = user_value.clone();
396    normalize_json_keys_snake_case(&mut normalized_for_check);
397    let nested_owner = normalized_for_check
398        .get("c2s")
399        .and_then(|c| c.get("owner"))
400        .is_some();
401    let flat_owner = normalized_for_check.get("owner").is_some();
402    if nested_owner || flat_owner {
403        let path = if nested_owner { "c2s.owner" } else { "owner" };
404        return Err(vec![path.to_string()]);
405    }
406    // adapter shorthand 注入的 owner 在 normalized 中存在 (post-
407    // expand_symbol_shorthand), 但 daemon proto 没 owner field.
408    // 用 ignore_paths 让 validator 不计 c2s.owner 为 unknown.
409    //
410    // v1.4.104 codex round 2 F2 + P1-001 follow-up: array shorthand path 也会
411    // 在用户传 `c2s.symbol` 时生成 `c2s.security_list` (1-element). ticker-
412    // statistic proto 没 security_list field, 同样需 ignore.
413    validate_for_path_with_ignore::<
414        futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticReq,
415    >(user_value, &["c2s.owner", "c2s.security_list"])
416}
417
418/// v1.4.106 codex 0500 ζ23-redo: 同 `validate_ticker_statistic_strict` —
419/// `/api/ticker-statistic-detail` 走 security shorthand 路径 (adapter
420/// `expand_symbol_shorthand` 在 validator 之前展开), 同样需:
421///   1. 显式 reject user-supplied `owner` (4 path: nested/flat × snake/Pascal)
422///   2. 容忍 adapter 注入的 `c2s.owner` / `c2s.security_list` (proto 没此字段)
423///   3. 调 generic validator 验证 strict 字段拼写 (typo guard)
424pub fn validate_ticker_statistic_detail_strict(user_value: &Value) -> Result<(), Vec<String>> {
425    let mut normalized_for_check = user_value.clone();
426    normalize_json_keys_snake_case(&mut normalized_for_check);
427    let nested_owner = normalized_for_check
428        .get("c2s")
429        .and_then(|c| c.get("owner"))
430        .is_some();
431    let flat_owner = normalized_for_check.get("owner").is_some();
432    if nested_owner || flat_owner {
433        let path = if nested_owner { "c2s.owner" } else { "owner" };
434        return Err(vec![path.to_string()]);
435    }
436    validate_for_path_with_ignore::<
437        futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticDetailReq,
438    >(user_value, &["c2s.owner", "c2s.security_list"])
439}
440
441/// v1.4.106 codex 0554 F2 [P2]: admin control-plane POST endpoints
442/// (`/api/admin/shutdown` + `/api/admin/reload`) 不带 proto request struct —
443/// handler 完全无视 body. 但 strict middleware 必须 reject 任何 user-supplied
444/// 字段, 避免 `{"force": true}` / `{"reason": "..."}` 之类 silent-accept (用户
445/// 以为生效, 实际 server 完全无视).
446///
447/// 行为: empty body / `{}` / `null` → OK; 任何 non-empty object / array /
448/// scalar → reject 列出 unknown 字段名.
449///
450/// 注意: middleware 顶层已对 empty bytes early-return; 本 fn 处理 `{}` 和
451/// `{"foo": 1}` 区分.
452pub fn validate_admin_empty_body(user_value: &Value) -> Result<(), Vec<String>> {
453    match user_value {
454        // `{}` / nullable → 通过
455        Value::Object(map) if map.is_empty() => Ok(()),
456        Value::Null => Ok(()),
457        // 非空 object → 列出所有 key
458        Value::Object(map) => Err(map.keys().cloned().collect()),
459        // 非 object (array / scalar) → 不允许 (admin endpoint 仅接受 `{}`/empty)
460        Value::Array(_) => Err(vec!["<root: array not allowed>".to_string()]),
461        Value::String(_) | Value::Number(_) | Value::Bool(_) => {
462            Err(vec!["<root: scalar not allowed>".to_string()])
463        }
464    }
465}
466
467/// adapter shorthand which injects `c2s.owner` (for endpoints like option-chain
468/// that need owner). ticker-statistic schema doesn't have owner, but injected
469/// owner shouldn't be flagged unknown. Caller path is responsible for rejecting
470/// **explicit user-supplied** owner *before* calling this fn.
471fn validate_for_path_with_ignore<Req>(
472    user_value: &Value,
473    ignore_paths: &[&str],
474) -> Result<(), Vec<String>>
475where
476    Req: Message + Default + DeserializeOwned + serde::Serialize,
477{
478    let mut normalized = user_value.clone();
479    normalize_json_keys_snake_case(&mut normalized);
480    apply_known_field_aliases(&mut normalized);
481    maybe_wrap_flat_body_as_c2s(&mut normalized);
482    maybe_expand_flat_trd_header(&mut normalized);
483    // expand_symbol_shorthand returns Result<(), String>; ignore here — if it
484    // fails, downstream handler will return its own 400. We're only checking
485    // unknown-field cases, not symbol shape.
486    let _ = expand_symbol_shorthand(&mut normalized);
487
488    // Try to deserialize. If deserialize fails (e.g. type mismatch), let
489    // downstream handler return its own typed error — not our concern.
490    let req: Req = match serde_json::from_value(normalized.clone()) {
491        Ok(r) => r,
492        Err(_) => return Ok(()),
493    };
494    let canonical = match serde_json::to_value(&req) {
495        Ok(v) => v,
496        Err(_) => return Ok(()),
497    };
498
499    let mut unknown = Vec::new();
500    collect_unknown_field_paths(&normalized, &canonical, "", &mut unknown);
501    // codex F3 fix: filter out ignore_paths (e.g. adapter-injected `c2s.owner`).
502    if !ignore_paths.is_empty() {
503        unknown.retain(|p| !ignore_paths.contains(&p.as_str()));
504    }
505    if unknown.is_empty() {
506        Ok(())
507    } else {
508        Err(unknown)
509    }
510}
511
512/// Recursively walk `orig` (user input post-normalization) vs `accepted`
513/// (canonical re-serialized typed-struct value). Push any path in `orig`
514/// that's missing from `accepted` to `out`.
515fn collect_unknown_field_paths(
516    orig: &Value,
517    accepted: &Value,
518    prefix: &str,
519    out: &mut Vec<String>,
520) {
521    match (orig, accepted) {
522        (Value::Object(o), Value::Object(a)) => {
523            for (k, v) in o {
524                let path = if prefix.is_empty() {
525                    k.clone()
526                } else {
527                    format!("{prefix}.{k}")
528                };
529                match a.get(k) {
530                    Some(av) => collect_unknown_field_paths(v, av, &path, out),
531                    None => out.push(path),
532                }
533            }
534        }
535        (Value::Array(o_arr), Value::Array(a_arr)) => {
536            // For Vec<T> typed fields, default-instantiated proto Request has
537            // empty Vec -> a_arr is []. We can only validate inner keys when
538            // a_arr has at least one element (rare for Request shapes).
539            if let Some(a_first) = a_arr.first() {
540                for (i, o_item) in o_arr.iter().enumerate() {
541                    let path = format!("{prefix}[{i}]");
542                    collect_unknown_field_paths(o_item, a_first, &path, out);
543                }
544            }
545        }
546        // Primitive vs anything: not a key-set check; OK.
547        _ => {}
548    }
549}
550
551#[cfg(test)]
552mod tests;