Skip to main content

rust_store_core/
rbac.rs

1//! RBAC 动态策略引擎(core 层判决插件;静态白名单引擎见 [`crate::permission`])
2//!
3//! 与静态引擎的叠加语义(deny-wins):RBAC 判决叠加在静态判决**之后**,任一拒绝即
4//! 拒绝;字段维度取交集;行条件与静态 owner 条件 `$and`。策略未注入(`registry.rbac()`
5//! 为 `None`)时全部原语直通,行为与现状逐位一致——增量收紧,永不放大既有权限面。
6//!
7//! 两个容易搞反的维度:
8//! - grant 之间(同一用户多角色 / 多 grant):**OR**(任一 grant 授予即放行其
9//!   条件命中的行 / 并集字段)——授权是并集;
10//! - 引擎之间(静态白名单 × RBAC):**AND**(deny-wins)——收紧取交集。
11//!
12//! 行为契约(与 [`crate::permission`] 一致,勿破坏):
13//! - `ctx: None` / `internal` / 豁免清单(`exempt_roles`,默认空)命中 → RBAC 不介入(直通);
14//! - 拒绝一律携带 `ERR_PERMISSION:` 稳定前缀(宿主按前缀映射 PermissionError),
15//!   二级标识 `RBAC:` 供日志与测试区分。
16
17use std::collections::HashSet;
18
19use serde_json::{json, Map, Value};
20
21use crate::permission::{get_writable_fields, Context};
22use crate::schema::{Registry, Schema};
23use crate::types::is_truthy;
24
25/// 策略模式:
26/// - [`RbacMode::Overlay`](默认):存在匹配 grant 才生效(按 model 增量收紧);
27/// - [`RbacMode::Enforce`]:受管角色(出现在 `roles` 声明中)对**所有** model 生效,
28///   无匹配 grant 即拒绝(default deny);未受管角色不介入。
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub enum RbacMode {
31    #[default]
32    Overlay,
33    Enforce,
34}
35
36/// 写动作粒度(对齐静态引擎 `check_write_perm` 的 `ERR_NO_WRITE` / `ERR_NO_DELETE` 分派)
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum WriteAction {
39    Insert,
40    Update,
41    Remove,
42}
43
44impl WriteAction {
45    pub fn as_str(&self) -> &'static str {
46        match self {
47            WriteAction::Insert => "insert",
48            WriteAction::Update => "update",
49            WriteAction::Remove => "remove",
50        }
51    }
52}
53
54/// action 字符串 → [`WriteAction`];非法值显式 Err(禁静默回落)
55pub fn write_action_from_str(s: &str) -> Result<WriteAction, String> {
56    match s {
57        "insert" => Ok(WriteAction::Insert),
58        "update" => Ok(WriteAction::Update),
59        "remove" => Ok(WriteAction::Remove),
60        other => Err(format!(
61            "RBAC action \"{other}\" 非法(仅支持 read / insert / update / remove)"
62        )),
63    }
64}
65
66#[derive(Debug, Clone)]
67pub struct Grant {
68    role: String,
69    model: String, // 精确 schema 名或 "*"(通配)
70    actions: Vec<String>, // 归一化后("write" 已展开),判定用 contains
71    read_fields: Option<HashSet<String>>,
72    write_fields: Option<HashSet<String>>,
73    owner_only: bool,
74    condition: Option<Value>, // 仅等值标量键值(解析期校验)
75}
76
77#[derive(Debug, Clone, Default)]
78pub struct RbacPolicy {
79    mode: RbacMode,
80    managed_roles: HashSet<String>,
81    grants: Vec<Grant>,
82}
83
84/// 单模型判决视图(`decide` 产出)
85#[derive(Debug, Clone)]
86pub struct ModelDecision {
87    pub allowed_actions: HashSet<String>, // read / insert / update / remove
88    /// None = 字段维度不收紧(与 [`crate::permission`] 的 Option 语义一致)
89    pub read_fields: Option<HashSet<String>>,
90    pub write_fields: Option<HashSet<String>>,
91}
92
93/// 拒绝哨兵:复用 `ERR_PERMISSION:` 前缀(宿主零改动映射 403),二级标识 `RBAC:`
94pub fn deny_msg(action: &str, model: &str) -> String {
95    format!("ERR_PERMISSION:RBAC:角色对 [{model}] 无 [{action}] 权限")
96}
97
98// ─── 解析(fail-fast,禁静默吞) ─────────────────────────────
99
100impl RbacPolicy {
101    /// 由策略 JSON 构建;任何非法形态显式 Err,绝不静默回落
102    pub fn from_json(v: &Value) -> Result<Self, String> {
103        let obj = v
104            .as_object()
105            .ok_or_else(|| "RBAC 策略必须是对象".to_string())?;
106
107        let mode = match obj.get("mode") {
108            None | Some(Value::Null) => RbacMode::Overlay,
109            Some(Value::String(s)) if s == "overlay" => RbacMode::Overlay,
110            Some(Value::String(s)) if s == "enforce" => RbacMode::Enforce,
111            Some(other) => {
112                return Err(format!(
113                    "RBAC mode {other} 非法(仅支持 overlay / enforce)"
114                ))
115            }
116        };
117
118        let mut managed_roles = HashSet::new();
119        match obj.get("roles") {
120            None | Some(Value::Null) => {}
121            Some(Value::Object(rm)) => {
122                for (name, _) in rm {
123                    if name.is_empty() {
124                        return Err("RBAC roles 含空角色名".to_string());
125                    }
126                    managed_roles.insert(name.clone());
127                }
128            }
129            Some(other) => return Err(format!("RBAC roles 必须是对象,实际 {other}")),
130        }
131        if mode == RbacMode::Enforce && managed_roles.is_empty() {
132            return Err("RBAC mode=enforce 时 roles 不能为空(无受管角色 = 策略空转,属配置错误)".to_string());
133        }
134
135        let mut grants = Vec::new();
136        match obj.get("grants") {
137            None | Some(Value::Null) => {}
138            Some(Value::Array(gs)) => {
139                for (i, g) in gs.iter().enumerate() {
140                    grants.push(parse_grant(i, g)?);
141                }
142            }
143            Some(other) => return Err(format!("RBAC grants 必须是数组,实际 {other}")),
144        }
145
146        Ok(Self { mode, managed_roles, grants })
147    }
148}
149
150fn parse_grant(idx: usize, v: &Value) -> Result<Grant, String> {
151    let o = v
152        .as_object()
153        .ok_or_else(|| format!("RBAC grants[{idx}] 必须是对象"))?;
154    let get_str = |k: &str| -> Result<String, String> {
155        o.get(k)
156            .and_then(|x| x.as_str())
157            .filter(|s| !s.is_empty())
158            .map(String::from)
159            .ok_or_else(|| format!("RBAC grants[{idx}].{k} 必须是非空字符串"))
160    };
161    let role = get_str("role")?;
162    let model = get_str("model")?;
163
164    let actions_raw = o
165        .get("actions")
166        .and_then(|x| x.as_array())
167        .ok_or_else(|| format!("RBAC grants[{idx}].actions 必须是非空数组"))?;
168    if actions_raw.is_empty() {
169        return Err(format!("RBAC grants[{idx}].actions 不能为空(不授权请移除该 grant)"));
170    }
171    let mut actions: Vec<String> = Vec::new();
172    for a in actions_raw {
173        let s = a
174            .as_str()
175            .ok_or_else(|| format!("RBAC grants[{idx}].actions 含非字符串项 {a}"))?;
176        let expanded: &[&str] = match s {
177            "read" => &["read"],
178            "insert" => &["insert"],
179            "update" => &["update"],
180            "remove" => &["remove"],
181            "write" => &["insert", "update", "remove"],
182            other => {
183                return Err(format!(
184                    "RBAC grants[{idx}].actions 含非法值 \"{other}\"(仅支持 read/insert/update/remove/write)"
185                ))
186            }
187        };
188        for e in expanded {
189            if !actions.iter().any(|x| x == e) {
190                actions.push((*e).to_string());
191            }
192        }
193    }
194
195    let parse_fields = |k: &str| -> Result<Option<HashSet<String>>, String> {
196        match o.get(k) {
197            None | Some(Value::Null) => Ok(None),
198            Some(Value::Array(fs)) => {
199                if fs.is_empty() {
200                    return Err(format!(
201                        "RBAC grants[{idx}].{k} 不能为空数组(全禁请移除该 grant)"
202                    ));
203                }
204                let mut set = HashSet::new();
205                for f in fs {
206                    let s = f
207                        .as_str()
208                        .filter(|s| !s.is_empty())
209                        .ok_or_else(|| format!("RBAC grants[{idx}].{k} 含非法项 {f}(须非空字符串)"))?;
210                    set.insert(s.to_string());
211                }
212                Ok(Some(set))
213            }
214            Some(other) => Err(format!("RBAC grants[{idx}].{k} 必须是字符串数组,实际 {other}")),
215        }
216    };
217    let read_fields = parse_fields("readFields")?;
218    let write_fields = parse_fields("writeFields")?;
219
220    let owner_only = o.get("ownerOnly").map(is_truthy).unwrap_or(false);
221
222    // 第一批仅等值标量条件:键非空且不得以 "$" 开头(操作符/逻辑组禁用),
223    // 值必须是标量——超出形态显式 Err,禁运行时静默失配
224    let condition = match o.get("condition") {
225        None | Some(Value::Null) => None,
226        Some(cm @ Value::Object(_)) => {
227            let entries = cm
228                .as_object()
229                .expect("已按 Object 匹配,此处必为对象");
230            for (k, val) in entries {
231                if k.is_empty() || k.starts_with('$') {
232                    return Err(format!(
233                        "RBAC grants[{idx}].condition 键 \"{k}\" 非法(第一批仅支持等值字段条件,键不得以 $ 开头)"
234                    ));
235                }
236                if !val.is_boolean() && !val.is_number() && !val.is_string() && !val.is_null() {
237                    return Err(format!(
238                        "RBAC grants[{idx}].condition[\"{k}\"] 必须是标量(string/number/bool/null),实际 {val}"
239                    ));
240                }
241            }
242            Some(cm.clone())
243        }
244        Some(other) => {
245            return Err(format!(
246                "RBAC grants[{idx}].condition 必须是对象(等值条件),实际 {other}"
247            ))
248        }
249    };
250
251    Ok(Grant { role, model, actions, read_fields, write_fields, owner_only, condition })
252}
253
254// ─── 判决 ────────────────────────────────────────────────────
255
256/// 有效角色集(对齐 `permission::evaluate`:`roles` 为空回落单 `role`)
257fn effective_roles(c: &Context) -> Vec<String> {
258    match &c.roles {
259        Some(r) if !r.is_empty() => r.clone(),
260        _ => vec![c.role.clone().unwrap_or_default()],
261    }
262}
263
264/// 核心判决:`None` = RBAC 不介入(无 ctx / internal / 豁免角色 / overlay 未覆盖 /
265/// enforce 未受管)。enforce 受管但 matched 为空 → `Some`(全 deny),这是
266/// default deny 的承载点。
267pub fn decide(
268    policy: &RbacPolicy,
269    exempt_roles: &[String],
270    ctx: Option<&Context>,
271    model: &str,
272) -> Option<ModelDecision> {
273    let c = ctx?;
274    if c.internal {
275        return None;
276    }
277    let roles = effective_roles(c);
278    if roles.iter().any(|r| exempt_roles.iter().any(|x| x == r)) {
279        return None;
280    }
281
282    let matched: Vec<&Grant> = policy
283        .grants
284        .iter()
285        .filter(|g| roles.iter().any(|r| r == &g.role) && (g.model == model || g.model == "*"))
286        .collect();
287
288    match policy.mode {
289        RbacMode::Overlay if matched.is_empty() => return None,
290        RbacMode::Enforce => {
291            let managed = roles.iter().any(|r| policy.managed_roles.contains(r));
292            if !managed {
293                return None;
294            }
295        }
296        _ => {}
297    }
298
299    // 字段集:grant 间 OR 语义——任一允许该动作的 grant 未声明字段集 = 授予全字段
300    // (整体不收紧);全部声明了才取并集收紧
301    let mut allowed_actions = HashSet::new();
302    let mut read_all = false;
303    let mut read_union: HashSet<String> = HashSet::new();
304    let mut write_all = false;
305    let mut write_union: HashSet<String> = HashSet::new();
306    for g in &matched {
307        for a in &g.actions {
308            allowed_actions.insert(a.clone());
309        }
310        if g.actions.iter().any(|a| a == "read") {
311            match &g.read_fields {
312                None => read_all = true,
313                Some(s) => read_union.extend(s.iter().cloned()),
314            }
315        }
316        if g
317            .actions
318            .iter()
319            .any(|a| a == "insert" || a == "update" || a == "remove")
320        {
321            match &g.write_fields {
322                None => write_all = true,
323                Some(s) => write_union.extend(s.iter().cloned()),
324            }
325        }
326    }
327    let read_fields = if read_all || read_union.is_empty() { None } else { Some(read_union) };
328    let write_fields = if write_all || write_union.is_empty() { None } else { Some(write_union) };
329    Some(ModelDecision { allowed_actions, read_fields, write_fields })
330}
331
332// ─── 叠加原语(registry.rbac() == None 时全部直通) ──────────
333
334/// 表级读判定(叠加在 `can_read_schema` 之后)
335pub fn ensure_read(
336    registry: &Registry,
337    schema: &Schema,
338    ctx: Option<&Context>,
339) -> Result<(), String> {
340    let Some(p) = registry.rbac() else { return Ok(()) };
341    match decide(p, &registry.role_rules().exempt_roles, ctx, &schema.name) {
342        None => Ok(()),
343        Some(d) if d.allowed_actions.contains("read") => Ok(()),
344        Some(_) => Err(deny_msg("read", &schema.name)),
345    }
346}
347
348/// 表级写判定(叠加在 `can_write_schema` 之后;upsert 路径裁定按 Insert 判)
349pub fn ensure_write(
350    registry: &Registry,
351    schema: &Schema,
352    ctx: Option<&Context>,
353    action: WriteAction,
354) -> Result<(), String> {
355    let Some(p) = registry.rbac() else { return Ok(()) };
356    match decide(p, &registry.role_rules().exempt_roles, ctx, &schema.name) {
357        None => Ok(()),
358        Some(d) if d.allowed_actions.contains(action.as_str()) => Ok(()),
359        Some(_) => Err(deny_msg(action.as_str(), &schema.name)),
360    }
361}
362
363/// 探针重入判决(单条 update/remove 的 ownerOnly / condition 命中判定)。
364///
365/// grant 间 OR 语义:任一允许该动作的 grant 无行限制 → 放行;有行限制且命中
366/// doc → 放行;全部不命中 → 拒绝(杜绝静默 0 行,对齐 no-error-masking)。
367pub fn ensure_write_on_doc(
368    registry: &Registry,
369    schema: &Schema,
370    ctx: Option<&Context>,
371    action: WriteAction,
372    doc: &Value,
373) -> Result<(), String> {
374    let Some(p) = registry.rbac() else { return Ok(()) };
375    let Some(c) = ctx else { return Ok(()) };
376    if c.internal {
377        return Ok(());
378    }
379    let Some(d) = decide(p, &registry.role_rules().exempt_roles, Some(c), &schema.name) else { return Ok(()) };
380    if !d.allowed_actions.contains(action.as_str()) {
381        return Err(deny_msg(action.as_str(), &schema.name));
382    }
383    let roles = effective_roles(c);
384    let hit = p.grants.iter().any(|g| {
385        g.actions.iter().any(|a| a == action.as_str())
386            && (g.model == schema.name || g.model == "*")
387            && roles.iter().any(|r| r == &g.role)
388            && match grant_row_restriction(g, c) {
389                None => true,
390                Some(cond) => doc_matches_eq(doc, &cond),
391            }
392    });
393    if hit {
394        Ok(())
395    } else {
396        Err(format!(
397            "ERR_PERMISSION:RBAC:角色对 [{}] 的 [{}] 限于本人/条件内文档",
398            schema.name,
399            action.as_str()
400        ))
401    }
402}
403
404/// 行级条件(grant 间 OR 合并):RBAC 未生效 / 存在无行限制的匹配 grant →
405/// `None`(不收紧);否则各 grant 行限制取 `$or` 串接。
406/// `action` ∈ {"read", "update", "remove"}(insert 无行级语义,调用方不传)。
407pub fn row_condition(
408    registry: &Registry,
409    schema: &Schema,
410    ctx: Option<&Context>,
411    action: &str,
412) -> Option<Value> {
413    let p = registry.rbac()?;
414    let c = ctx?;
415    if c.internal {
416        return None;
417    }
418    decide(p, &registry.role_rules().exempt_roles, Some(c), &schema.name)?;
419    let roles = effective_roles(c);
420    let mut conds: Vec<Value> = Vec::new();
421    for g in p.grants.iter().filter(|g| {
422        g.actions.iter().any(|a| a == action)
423            && (g.model == schema.name || g.model == "*")
424            && roles.iter().any(|r| r == &g.role)
425    }) {
426        match grant_row_restriction(g, c) {
427            None => return None, // 任一 grant 无行限制 → 无限制
428            Some(v) => conds.push(v),
429        }
430    }
431    match conds.len() {
432        0 => None,
433        1 => Some(conds.pop().unwrap_or(Value::Null)),
434        _ => Some(json!({ "$or": conds })),
435    }
436}
437
438/// 在静态引擎产出的 owner 条件(`merge_owner_condition` 结果)之上叠加 RBAC 行
439/// 条件:两引擎间 `$and`(deny-wins)。任一为 `None` → 取另一者;空对象视为无条件。
440pub fn merge_row_condition(
441    registry: &Registry,
442    schema: &Schema,
443    ctx: Option<&Context>,
444    action: &str,
445    base: Option<Value>,
446) -> Option<Value> {
447    let meaningful = |v: Option<Value>| -> Option<Value> {
448        v.filter(|x| x.as_object().map(|o| !o.is_empty()).unwrap_or(false))
449    };
450    let rbac_cond = row_condition(registry, schema, ctx, action);
451    match (meaningful(base), rbac_cond) {
452        (None, r) => r,
453        (b, None) => b,
454        (Some(b), Some(r)) => Some(json!({ "$and": [b, r] })),
455    }
456}
457
458/// 字段读交集:`base`(静态 `get_readable_fields` 结果)与 RBAC readFields 取交。
459/// `_id` 随 base 保留(文档标识豁免,同 `filter_writable_data` 的 D-03 理由)。
460pub fn overlay_readable_fields(
461    registry: &Registry,
462    model: &str,
463    ctx: Option<&Context>,
464    base: Option<HashSet<String>>,
465) -> Option<HashSet<String>> {
466    let Some(p) = registry.rbac() else { return base };
467    let Some(d) = decide(p, &registry.role_rules().exempt_roles, ctx, model) else { return base };
468    let Some(rf) = &d.read_fields else { return base };
469    match base {
470        None => {
471            let mut s: HashSet<String> = rf.clone();
472            s.insert("_id".to_string());
473            Some(s)
474        }
475        Some(b) => {
476            let keep_id = b.contains("_id");
477            let mut s: HashSet<String> = b.intersection(rf).cloned().collect();
478            if keep_id {
479                s.insert("_id".to_string());
480            }
481            Some(s)
482        }
483    }
484}
485
486/// 字段写交集(语义同 [`overlay_readable_fields`],作用于 writeFields)
487pub fn overlay_writable_fields(
488    registry: &Registry,
489    model: &str,
490    ctx: Option<&Context>,
491    base: Option<HashSet<String>>,
492) -> Option<HashSet<String>> {
493    let Some(p) = registry.rbac() else { return base };
494    let Some(d) = decide(p, &registry.role_rules().exempt_roles, ctx, model) else { return base };
495    let Some(wf) = &d.write_fields else { return base };
496    match base {
497        None => {
498            let mut s: HashSet<String> = wf.clone();
499            s.insert("_id".to_string());
500            Some(s)
501        }
502        Some(b) => {
503            let keep_id = b.contains("_id");
504            let mut s: HashSet<String> = b.intersection(wf).cloned().collect();
505            if keep_id {
506                s.insert("_id".to_string());
507            }
508            Some(s)
509        }
510    }
511}
512
513/// 写入数据过滤(RBAC 感知版,供写路径调用点整体替换
514/// `permission::filter_writable_data`):可写字段 = 静态 writable ∩ RBAC writeFields。
515/// 与 [`crate::permission::filter_writable_data`] 逐行同构(`_id` 豁免、点号按 root),
516/// 仅集合来源叠加 RBAC 交集——静态引擎文件零改动。
517pub fn filter_writable_data_overlay(
518    registry: &Registry,
519    schema: &Schema,
520    ctx: Option<&Context>,
521    data: &Value,
522) -> Value {
523    if registry.rbac().is_none() {
524        return crate::permission::filter_writable_data(registry.role_rules(), schema, ctx, data);
525    }
526    let base = get_writable_fields(registry.role_rules(), schema, ctx);
527    let writable = overlay_writable_fields(registry, &schema.name, ctx, base);
528    let Some(writable) = writable else {
529        return data.clone();
530    };
531    let Some(obj) = data.as_object() else {
532        return data.clone();
533    };
534    let mut result = Map::new();
535    for (key, val) in obj {
536        let root = key.split('.').next().unwrap_or(key);
537        if key == "_id" || writable.contains(root) {
538            result.insert(key.clone(), val.clone());
539        }
540    }
541    Value::Object(result)
542}
543
544/// 单字段可读判定(RBAC 感知版):静态 `is_field_readable` ∧ RBAC readFields。
545/// 点号路径按 root 字段判定(与静态语义一致);`ctx=None` / internal / RBAC 未生效 → 放行。
546pub fn is_field_readable_overlay(
547    registry: &Registry,
548    schema: &Schema,
549    ctx: Option<&Context>,
550    field: &str,
551) -> bool {
552    if !crate::permission::is_field_readable(registry.role_rules(), schema, ctx, field) {
553        return false;
554    }
555    let Some(p) = registry.rbac() else { return true };
556    let Some(c) = ctx else { return true };
557    if c.internal {
558        return true;
559    }
560    let Some(d) = decide(p, &registry.role_rules().exempt_roles, Some(c), &schema.name) else { return true };
561    match &d.read_fields {
562        None => true,
563        Some(rf) => rf.contains(field.split('.').next().unwrap_or(field)),
564    }
565}
566
567/// 探针命令需额外投影的字段(单条 update/remove 的 RBAC 行级判定依赖):
568/// 各 grant condition 的字段键(ownerOnly 的 `createdBy` 由调用方静态并入)。
569/// 无 RBAC 行级限制 → 空表(调用方维持既有 `{_id, createdBy}` 投影)。
570pub fn probe_condition_fields(
571    registry: &Registry,
572    schema: &Schema,
573    ctx: Option<&Context>,
574    action: &str,
575) -> Vec<String> {
576    let Some(p) = registry.rbac() else { return Vec::new() };
577    let Some(c) = ctx else { return Vec::new() };
578    if c.internal {
579        return Vec::new();
580    }
581    if decide(p, &registry.role_rules().exempt_roles, Some(c), &schema.name).is_none() {
582        return Vec::new();
583    }
584    let roles = effective_roles(c);
585    let mut fields = Vec::new();
586    for g in p.grants.iter().filter(|g| {
587        g.actions.iter().any(|a| a == action)
588            && (g.model == schema.name || g.model == "*")
589            && roles.iter().any(|r| r == &g.role)
590    }) {
591        if let Some(cond) = &g.condition {
592            if let Some(obj) = cond.as_object() {
593                for k in obj.keys() {
594                    if !fields.iter().any(|x: &String| x == k) {
595                        fields.push(k.clone());
596                    }
597                }
598            }
599        }
600    }
601    fields
602}
603
604// ─── 内部工具 ────────────────────────────────────────────────
605
606/// 单 grant 的行限制:`None` = 无限制;`ownerOnly` → `{createdBy: userId}`;
607/// `condition` → 原样;两者并存 → `$and`。`ownerOnly` 但 userId 缺失 → 恒假哨兵
608/// (禁当 `None`——那是静默放大)。
609fn grant_row_restriction(g: &Grant, c: &Context) -> Option<Value> {
610    if !g.owner_only && g.condition.is_none() {
611        return None;
612    }
613    let owner = if g.owner_only {
614        let uid = c.user_id.clone().unwrap_or_default();
615        if uid.is_empty() {
616            // 恒假条件:__rbac_impossible__ 不可能在文档出现 → doc_matches_eq 恒 false
617            Some(json!({ "__rbac_impossible__": true }))
618        } else {
619            Some(json!({ "createdBy": uid }))
620        }
621    } else {
622        None
623    };
624    match (owner, g.condition.clone()) {
625        (None, cond) => cond,
626        (owner, None) => owner,
627        (Some(o), Some(cond)) => Some(json!({ "$and": [o, cond] })),
628    }
629}
630
631/// 等值条件命中判定(`cond` 为 `{字段: 标量}`,解析期已保证);哨兵键恒不命中
632fn doc_matches_eq(doc: &Value, cond: &Value) -> bool {
633    let (Some(obj), Some(cond_map)) = (doc.as_object(), cond.as_object()) else {
634        return false;
635    };
636    cond_map.iter().all(|(k, v)| obj.get(k).map(|dv| dv == v).unwrap_or(false))
637}