Skip to main content

sui_config/
transaction_deny_config.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::BTreeSet;
5
6use serde::{Deserialize, Serialize};
7use sui_types::base_types::{AuthorityName, ObjectID, SuiAddress};
8pub use sui_types::transaction_deny_rules::{DenyElementKind, TransactionDenyRules};
9
10use crate::dynamic_transaction_signing_checks::{
11    DynamicCheckRunnerContext, DynamicCheckRunnerError,
12};
13
14/// Configuration for activating recommended `TransactionDenyConfig` rules shared by
15/// peers via consensus. The operator pre-defines named rulesets, each gated on a
16/// stake threshold among an eligible set of validators; per-kind "default" buckets
17/// govern individual rule elements peers propose outside of any pre-listed ruleset.
18#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
19#[serde(rename_all = "kebab-case")]
20pub struct PeerDenySyncConfig {
21    /// Pre-listed rulesets. Each activates only when eligible voting stake reaches
22    /// its threshold.
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub rulesets: Vec<SharedDenyRuleset>,
25
26    /// Per-kind default buckets. Each bucket covers a disjoint subset of
27    /// `DenyElementKind` and threshold-gates each proposed element of those kinds
28    /// individually.
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub default_buckets: Vec<DefaultDenyBucket>,
31
32    #[serde(default)]
33    pub broadcast_on_startup: bool,
34
35    #[serde(default)]
36    pub broadcast_on_epoch_change: bool,
37}
38
39/// A pre-listed ruleset becomes effective when validators holding at least
40/// `threshold.stake_threshold_percent` of the eligible stake have each proposed a
41/// superset of `rules`.
42#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
43#[serde(rename_all = "kebab-case")]
44pub struct SharedDenyRuleset {
45    /// Operator-chosen identifier used in metrics.
46    pub name: String,
47    pub rules: TransactionDenyRules,
48    #[serde(flatten)]
49    pub threshold: SharedDenyRuleThreshold,
50}
51
52/// Eligibility + stake threshold criteria for activating a proposed deny-rule.
53#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
54#[serde(rename_all = "kebab-case")]
55pub struct SharedDenyRuleThreshold {
56    pub eligibility: ValidatorEligibility,
57    /// Whole-number percent (1..=100) of eligible stake that must vote to activate.
58    pub stake_threshold_percent: u16,
59}
60
61/// A per-kind default bucket. Each bucket covers a disjoint subset of
62/// `DenyElementKind`; a proposed element of one of `element_kinds` activates when
63/// eligible voting stake reaches `threshold`.
64#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
65#[serde(rename_all = "kebab-case")]
66pub struct DefaultDenyBucket {
67    /// Operator-chosen identifier used in metrics and the admin dump.
68    pub name: String,
69    /// Element kinds routed to this bucket. Must be non-empty and disjoint from the
70    /// kinds in every other bucket.
71    pub element_kinds: BTreeSet<DenyElementKind>,
72    #[serde(flatten)]
73    pub threshold: SharedDenyRuleThreshold,
74}
75
76/// Which validators' proposals count toward a deny-rule activation's stake threshold.
77#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
78#[serde(rename_all = "kebab-case")]
79pub enum ValidatorEligibility {
80    /// Only the listed authorities are eligible.
81    Allowlist(BTreeSet<AuthorityName>),
82    /// All committee members except the listed authorities are eligible.
83    Denylist(BTreeSet<AuthorityName>),
84}
85
86impl ValidatorEligibility {
87    pub fn is_eligible(&self, name: &AuthorityName) -> bool {
88        match self {
89            ValidatorEligibility::Allowlist(set) => set.contains(name),
90            ValidatorEligibility::Denylist(set) => !set.contains(name),
91        }
92    }
93}
94
95impl Default for ValidatorEligibility {
96    fn default() -> Self {
97        // An empty denylist makes every committee member eligible.
98        ValidatorEligibility::Denylist(BTreeSet::new())
99    }
100}
101
102impl PeerDenySyncConfig {
103    /// Validate operator-provided settings. Called at manager construction.
104    pub fn validate(&self) -> Result<(), String> {
105        let mut names = BTreeSet::new();
106        for ruleset in &self.rulesets {
107            if ruleset.name.is_empty() {
108                return Err("rulesets entry has an empty name".to_string());
109            }
110            if !names.insert(ruleset.name.as_str()) {
111                return Err(format!("duplicate rulesets name: {}", ruleset.name));
112            }
113            if ruleset.rules.is_empty() {
114                return Err(format!("rulesets entry {} has empty rules", ruleset.name));
115            }
116            ruleset.threshold.validate(&ruleset.name)?;
117        }
118        let mut seen_kinds = BTreeSet::new();
119        for bucket in &self.default_buckets {
120            if bucket.name.is_empty() {
121                return Err("default_buckets entry has an empty name".to_string());
122            }
123            if !names.insert(bucket.name.as_str()) {
124                return Err(format!(
125                    "default_buckets name collides with another ruleset or bucket: {}",
126                    bucket.name,
127                ));
128            }
129            if bucket.element_kinds.is_empty() {
130                return Err(format!(
131                    "default_buckets entry {} has empty element_kinds",
132                    bucket.name,
133                ));
134            }
135            for kind in &bucket.element_kinds {
136                if !seen_kinds.insert(*kind) {
137                    return Err(format!(
138                        "default_buckets entry {} claims element kind {:?} already \
139                        claimed by another bucket",
140                        bucket.name, kind,
141                    ));
142                }
143            }
144            bucket.threshold.validate(&bucket.name)?;
145        }
146        Ok(())
147    }
148}
149
150impl SharedDenyRuleThreshold {
151    /// Validate this threshold's percent is within 1..=100. `label` is included in the
152    /// error message to identify which threshold failed. 0% is rejected because it
153    /// would degenerate to "always active" — almost certainly not the operator's intent.
154    pub fn validate(&self, label: &str) -> Result<(), String> {
155        if self.stake_threshold_percent == 0 || self.stake_threshold_percent > 100 {
156            return Err(format!(
157                "{label}: stake_threshold_percent must be 1..=100, got {}",
158                self.stake_threshold_percent,
159            ));
160        }
161        Ok(())
162    }
163}
164
165#[derive(Clone, Debug, Default, Deserialize, Serialize)]
166#[serde(rename_all = "kebab-case")]
167pub struct TransactionDenyConfig {
168    /// All shareable settings live here. Flattened so the YAML schema is unchanged.
169    #[serde(flatten)]
170    rules: TransactionDenyRules,
171
172    /// Dynamic transaction checks to run on transactions.
173    /// Program is loaded at deserialization time to ensure that any syntactic issues are caught
174    /// immediately.
175    /// Local-only: never propagated through the consensus-shared recommendation flow.
176    #[serde(
177        default,
178        skip_serializing_if = "Option::is_none",
179        serialize_with = "crate::dynamic_transaction_signing_checks::serialize_dynamic_transaction_checks",
180        deserialize_with = "crate::dynamic_transaction_signing_checks::deserialize_dynamic_transaction_checks"
181    )]
182    dynamic_transaction_checks: Option<DynamicCheckRunnerContext>,
183}
184
185impl TransactionDenyConfig {
186    pub fn rules(&self) -> &TransactionDenyRules {
187        &self.rules
188    }
189
190    pub fn get_object_deny_set(&self) -> &BTreeSet<ObjectID> {
191        &self.rules.object_deny_list
192    }
193
194    pub fn get_package_deny_set(&self) -> &BTreeSet<ObjectID> {
195        &self.rules.package_deny_list
196    }
197
198    pub fn get_address_deny_set(&self) -> &BTreeSet<SuiAddress> {
199        &self.rules.address_deny_list
200    }
201
202    pub fn package_publish_disabled(&self) -> bool {
203        self.rules.package_publish_disabled
204    }
205
206    pub fn package_upgrade_disabled(&self) -> bool {
207        self.rules.package_upgrade_disabled
208    }
209
210    pub fn shared_object_disabled(&self) -> bool {
211        self.rules.shared_object_disabled
212    }
213
214    pub fn user_transaction_disabled(&self) -> bool {
215        self.rules.user_transaction_disabled
216    }
217
218    pub fn gasless_disabled(&self) -> bool {
219        self.rules.gasless_disabled
220    }
221
222    pub fn receiving_objects_disabled(&self) -> bool {
223        self.rules.receiving_objects_disabled
224    }
225
226    pub fn zklogin_sig_disabled(&self) -> bool {
227        self.rules.zklogin_sig_disabled
228    }
229
230    pub fn zklogin_disabled_providers(&self) -> &BTreeSet<String> {
231        &self.rules.zklogin_disabled_providers
232    }
233
234    pub fn dynamic_transaction_checks(&self) -> &Option<DynamicCheckRunnerContext> {
235        &self.dynamic_transaction_checks
236    }
237
238    pub fn has_dynamic_transaction_checks(&self) -> bool {
239        self.dynamic_transaction_checks.is_some()
240    }
241
242    /// Return a copy of this config with `rules` replaced, carrying
243    /// `dynamic_transaction_checks` over verbatim (it is local-only and never shared).
244    pub fn with_rules(&self, rules: TransactionDenyRules) -> Self {
245        Self {
246            rules,
247            dynamic_transaction_checks: self.dynamic_transaction_checks.clone(),
248        }
249    }
250}
251
252#[derive(Default)]
253pub struct TransactionDenyConfigBuilder {
254    config: TransactionDenyConfig,
255}
256
257impl TransactionDenyConfigBuilder {
258    pub fn new() -> Self {
259        Self::default()
260    }
261
262    pub fn build(self) -> TransactionDenyConfig {
263        self.config
264    }
265
266    pub fn disable_user_transaction(mut self) -> Self {
267        self.config.rules.user_transaction_disabled = true;
268        self
269    }
270
271    pub fn disable_gasless(mut self) -> Self {
272        self.config.rules.gasless_disabled = true;
273        self
274    }
275
276    pub fn disable_shared_object_transaction(mut self) -> Self {
277        self.config.rules.shared_object_disabled = true;
278        self
279    }
280
281    pub fn disable_package_publish(mut self) -> Self {
282        self.config.rules.package_publish_disabled = true;
283        self
284    }
285
286    pub fn disable_package_upgrade(mut self) -> Self {
287        self.config.rules.package_upgrade_disabled = true;
288        self
289    }
290
291    pub fn disable_receiving_objects(mut self) -> Self {
292        self.config.rules.receiving_objects_disabled = true;
293        self
294    }
295
296    pub fn add_denied_object(mut self, id: ObjectID) -> Self {
297        self.config.rules.object_deny_list.insert(id);
298        self
299    }
300
301    pub fn add_denied_address(mut self, address: SuiAddress) -> Self {
302        self.config.rules.address_deny_list.insert(address);
303        self
304    }
305
306    pub fn add_denied_package(mut self, id: ObjectID) -> Self {
307        self.config.rules.package_deny_list.insert(id);
308        self
309    }
310
311    pub fn disable_zklogin_sig(mut self) -> Self {
312        self.config.rules.zklogin_sig_disabled = true;
313        self
314    }
315
316    pub fn add_zklogin_disabled_provider(mut self, provider: String) -> Self {
317        self.config
318            .rules
319            .zklogin_disabled_providers
320            .insert(provider);
321        self
322    }
323
324    pub fn add_dynamic_transaction_checks(
325        mut self,
326        checks: String,
327    ) -> Result<Self, DynamicCheckRunnerError> {
328        self.config.dynamic_transaction_checks = Some(DynamicCheckRunnerContext::new(checks)?);
329        Ok(self)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use sui_types::base_types::dbg_addr;
337
338    #[test]
339    fn with_rules_replaces_rules_and_keeps_dynamic_checks() {
340        let starlark =
341            "def predicate(tx_data, tx_signatures, input_objects, receiving_objects):\n    pass\n"
342                .to_string();
343        let local = TransactionDenyConfigBuilder::new()
344            .add_denied_object(ObjectID::from_single_byte(1))
345            .add_dynamic_transaction_checks(starlark)
346            .expect("starlark should parse")
347            .build();
348
349        let mut new_rules = TransactionDenyRules::default();
350        new_rules
351            .object_deny_list
352            .insert(ObjectID::from_single_byte(2));
353        new_rules.user_transaction_disabled = true;
354
355        let updated = local.with_rules(new_rules);
356
357        assert!(
358            !updated
359                .get_object_deny_set()
360                .contains(&ObjectID::from_single_byte(1))
361        );
362        assert!(
363            updated
364                .get_object_deny_set()
365                .contains(&ObjectID::from_single_byte(2))
366        );
367        assert!(updated.user_transaction_disabled());
368        // dynamic_transaction_checks is local-only and carried over verbatim.
369        assert!(updated.has_dynamic_transaction_checks());
370    }
371
372    #[test]
373    fn transaction_deny_config_yaml_preserves_existing_schema() {
374        // Older operator configs use kebab-case fields at the top level of
375        // transaction-deny-config; the flatten attribute must keep that schema.
376        let yaml = r#"
377            package-publish-disabled: true
378            user-transaction-disabled: false
379            object-deny-list:
380              - "0x0101010101010101010101010101010101010101010101010101010101010101"
381        "#;
382        let cfg: TransactionDenyConfig = serde_yaml::from_str(yaml).unwrap();
383        assert!(cfg.package_publish_disabled());
384        assert!(!cfg.user_transaction_disabled());
385        assert_eq!(cfg.get_object_deny_set().len(), 1);
386    }
387
388    /// Forward round-trip with every collection field populated. Catches schema drift
389    /// between `#[serde(flatten)] rules` and the custom (de)serializer on
390    /// `dynamic_transaction_checks` — a refactor that touches either is most likely
391    /// to trip here.
392    #[test]
393    fn transaction_deny_config_yaml_round_trip() {
394        let cfg = TransactionDenyConfigBuilder::new()
395            .add_denied_object(ObjectID::from_single_byte(1))
396            .add_denied_object(ObjectID::from_single_byte(2))
397            .add_denied_package(ObjectID::from_single_byte(3))
398            .add_denied_address(dbg_addr(4))
399            .disable_user_transaction()
400            .disable_gasless()
401            .disable_shared_object_transaction()
402            .disable_package_publish()
403            .disable_package_upgrade()
404            .disable_receiving_objects()
405            .disable_zklogin_sig()
406            .add_zklogin_disabled_provider("Google".to_string())
407            .add_zklogin_disabled_provider("Apple".to_string())
408            .build();
409
410        let yaml = serde_yaml::to_string(&cfg).expect("serialize");
411        let parsed: TransactionDenyConfig = serde_yaml::from_str(&yaml).expect("deserialize");
412
413        // Every field round-trips. Compare via the public accessors since
414        // TransactionDenyConfig doesn't derive PartialEq (Starlark context).
415        assert_eq!(cfg.rules(), parsed.rules());
416        assert_eq!(
417            cfg.has_dynamic_transaction_checks(),
418            parsed.has_dynamic_transaction_checks(),
419        );
420    }
421
422    /// The pre-refactor schema used `Vec<ObjectID>` and `HashSet<String>`. On the wire
423    /// (YAML lists), both serialize identically to the new `BTreeSet`-based schema.
424    /// This test pins that backward compatibility down so a future refactor that
425    /// changes the on-wire shape will fail loudly.
426    #[test]
427    fn transaction_deny_config_yaml_pre_refactor_schema_parses() {
428        // Hand-rolled YAML that matches what the old Vec/HashSet code would emit:
429        // sequences for the list fields, with concrete entries (not the empty-list
430        // serialization a fresh BTreeSet would produce).
431        let yaml = r#"
432            object-deny-list:
433              - "0x0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a"
434              - "0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"
435            package-deny-list:
436              - "0x0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c"
437            address-deny-list:
438              - "0x0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d"
439            package-publish-disabled: true
440            package-upgrade-disabled: true
441            shared-object-disabled: true
442            user-transaction-disabled: true
443            gasless-disabled: true
444            receiving-objects-disabled: true
445            zklogin-sig-disabled: true
446            zklogin-disabled-providers:
447              - Google
448              - Apple
449        "#;
450        let cfg: TransactionDenyConfig = serde_yaml::from_str(yaml).unwrap();
451        assert_eq!(cfg.get_object_deny_set().len(), 2);
452        assert_eq!(cfg.get_package_deny_set().len(), 1);
453        assert_eq!(cfg.get_address_deny_set().len(), 1);
454        assert_eq!(cfg.zklogin_disabled_providers().len(), 2);
455        assert!(cfg.user_transaction_disabled());
456        assert!(cfg.zklogin_sig_disabled());
457    }
458
459    /// `#[serde(flatten)]` plus the custom `dynamic_transaction_checks`
460    /// (de)serializer is the trickiest serde combination in this struct. Verify
461    /// they coexist correctly: a config with a populated Starlark program and
462    /// flattened rule fields round-trips without one stomping on the other.
463    #[test]
464    fn transaction_deny_config_yaml_round_trip_with_dynamic_checks() {
465        // A trivially-valid Starlark program that always passes.
466        let starlark =
467            "def predicate(tx_data, tx_signatures, input_objects, receiving_objects):\n    pass\n"
468                .to_string();
469        let cfg = TransactionDenyConfigBuilder::new()
470            .add_denied_object(ObjectID::from_single_byte(1))
471            .disable_package_publish()
472            .add_dynamic_transaction_checks(starlark)
473            .expect("starlark should parse");
474        let cfg = cfg.build();
475
476        let yaml = serde_yaml::to_string(&cfg).expect("serialize");
477        let parsed: TransactionDenyConfig = serde_yaml::from_str(&yaml).expect("deserialize");
478
479        // Both flattened rule fields and the custom-serialized Starlark survive.
480        assert_eq!(cfg.rules(), parsed.rules());
481        assert!(parsed.has_dynamic_transaction_checks());
482    }
483
484    /// A populated `PeerDenySyncConfig` round-trips through YAML — pins down the
485    /// `#[serde(flatten)]` on the ruleset/bucket threshold and the
486    /// `ValidatorEligibility` enum representation, the two serde-fragile parts of the
487    /// schema.
488    #[test]
489    fn peer_deny_sync_config_yaml_round_trip() {
490        let rules = TransactionDenyRules {
491            package_publish_disabled: true,
492            object_deny_list: std::iter::once(ObjectID::from_single_byte(7)).collect(),
493            ..Default::default()
494        };
495        let config = PeerDenySyncConfig {
496            rulesets: vec![SharedDenyRuleset {
497                name: "incident".to_string(),
498                rules,
499                threshold: SharedDenyRuleThreshold {
500                    eligibility: ValidatorEligibility::Denylist(BTreeSet::new()),
501                    stake_threshold_percent: 67,
502                },
503            }],
504            default_buckets: vec![
505                DefaultDenyBucket {
506                    name: "deny-list-entries".to_string(),
507                    element_kinds: [
508                        DenyElementKind::Object,
509                        DenyElementKind::Package,
510                        DenyElementKind::Address,
511                    ]
512                    .into_iter()
513                    .collect(),
514                    threshold: SharedDenyRuleThreshold {
515                        eligibility: ValidatorEligibility::Allowlist(BTreeSet::new()),
516                        stake_threshold_percent: 50,
517                    },
518                },
519                DefaultDenyBucket {
520                    name: "kill-switches".to_string(),
521                    element_kinds: [DenyElementKind::SharedObjectDisabled]
522                        .into_iter()
523                        .collect(),
524                    threshold: SharedDenyRuleThreshold {
525                        eligibility: ValidatorEligibility::Denylist(BTreeSet::new()),
526                        stake_threshold_percent: 90,
527                    },
528                },
529            ],
530            broadcast_on_startup: true,
531            broadcast_on_epoch_change: false,
532        };
533
534        let yaml = serde_yaml::to_string(&config).expect("serialize");
535        let parsed: PeerDenySyncConfig = serde_yaml::from_str(&yaml).expect("deserialize");
536        assert_eq!(config, parsed);
537    }
538
539    #[test]
540    fn validate_rejects_malformed_rulesets() {
541        let nonempty = || TransactionDenyRules {
542            package_publish_disabled: true,
543            ..Default::default()
544        };
545        let ruleset = |name: &str, rules: TransactionDenyRules, percent: u16| SharedDenyRuleset {
546            name: name.to_string(),
547            rules,
548            threshold: SharedDenyRuleThreshold {
549                eligibility: ValidatorEligibility::default(),
550                stake_threshold_percent: percent,
551            },
552        };
553        let bucket = |name: &str, kinds: &[DenyElementKind], percent: u16| DefaultDenyBucket {
554            name: name.to_string(),
555            element_kinds: kinds.iter().copied().collect(),
556            threshold: SharedDenyRuleThreshold {
557                eligibility: ValidatorEligibility::default(),
558                stake_threshold_percent: percent,
559            },
560        };
561        let config = |rulesets, default_buckets| PeerDenySyncConfig {
562            rulesets,
563            default_buckets,
564            ..Default::default()
565        };
566
567        // A well-formed config validates.
568        assert!(
569            config(vec![ruleset("a", nonempty(), 50)], vec![])
570                .validate()
571                .is_ok()
572        );
573        // A config with two default buckets on disjoint kinds validates.
574        assert!(
575            config(
576                vec![],
577                vec![
578                    bucket("objs", &[DenyElementKind::Object], 50),
579                    bucket("kill", &[DenyElementKind::UserTransactionDisabled], 90),
580                ],
581            )
582            .validate()
583            .is_ok()
584        );
585        // Empty ruleset name.
586        assert!(
587            config(vec![ruleset("", nonempty(), 50)], vec![])
588                .validate()
589                .is_err()
590        );
591        // Duplicate ruleset names.
592        assert!(
593            config(
594                vec![
595                    ruleset("dup", nonempty(), 50),
596                    ruleset("dup", nonempty(), 50)
597                ],
598                vec![],
599            )
600            .validate()
601            .is_err()
602        );
603        // Empty ruleset rules.
604        assert!(
605            config(
606                vec![ruleset("a", TransactionDenyRules::default(), 50)],
607                vec![],
608            )
609            .validate()
610            .is_err()
611        );
612        // Threshold above 100 on a pre-listed ruleset.
613        assert!(
614            config(vec![ruleset("a", nonempty(), 101)], vec![])
615                .validate()
616                .is_err()
617        );
618        // Threshold above 100 on a default bucket.
619        assert!(
620            config(vec![], vec![bucket("b", &[DenyElementKind::Object], 101)],)
621                .validate()
622                .is_err()
623        );
624        // 0% threshold rejected on a pre-listed ruleset (would be "always active").
625        assert!(
626            config(vec![ruleset("a", nonempty(), 0)], vec![])
627                .validate()
628                .is_err()
629        );
630        // 0% threshold rejected on a default bucket.
631        assert!(
632            config(vec![], vec![bucket("b", &[DenyElementKind::Object], 0)])
633                .validate()
634                .is_err()
635        );
636        // Empty bucket name.
637        assert!(
638            config(vec![], vec![bucket("", &[DenyElementKind::Object], 50)])
639                .validate()
640                .is_err()
641        );
642        // Duplicate bucket names.
643        assert!(
644            config(
645                vec![],
646                vec![
647                    bucket("dup", &[DenyElementKind::Object], 50),
648                    bucket("dup", &[DenyElementKind::Address], 50),
649                ],
650            )
651            .validate()
652            .is_err()
653        );
654        // Bucket name colliding with a ruleset name.
655        assert!(
656            config(
657                vec![ruleset("shared", nonempty(), 50)],
658                vec![bucket("shared", &[DenyElementKind::Object], 50)],
659            )
660            .validate()
661            .is_err()
662        );
663        // Empty element_kinds.
664        assert!(
665            config(vec![], vec![bucket("b", &[], 50)])
666                .validate()
667                .is_err()
668        );
669        // A DenyElementKind claimed by two buckets.
670        assert!(
671            config(
672                vec![],
673                vec![
674                    bucket("a", &[DenyElementKind::Object], 50),
675                    bucket("b", &[DenyElementKind::Object], 50),
676                ],
677            )
678            .validate()
679            .is_err()
680        );
681    }
682}