1use std::borrow::Cow;
23use std::sync::{LazyLock, OnceLock};
24
25use regex::Regex;
26
27use crate::{LivenessSpec, PatternMetadata, RotationSpec, SecretPattern, Severity};
28
29pub struct Builtin {
35 pub id: &'static str,
37 pub display_name: &'static str,
39 pub severity: Severity,
41 pub regex_src: &'static str,
43 pub regex: OnceLock<Regex>,
46 pub metadata: Option<PatternMetadata>,
49 pub rotation: Option<RotationSpec>,
52 pub liveness: Option<LivenessSpec>,
55}
56
57impl Builtin {
58 fn compiled_regex(&self) -> &Regex {
63 self.regex.get_or_init(|| {
64 Regex::new(self.regex_src).unwrap_or_else(|e| {
65 panic!(
66 "built-in pattern '{}' has malformed regex `{}`: {e}",
67 self.id, self.regex_src
68 )
69 })
70 })
71 }
72}
73
74impl SecretPattern for Builtin {
75 fn id(&self) -> &str {
76 self.id
77 }
78 fn display_name(&self) -> &str {
79 self.display_name
80 }
81 fn severity(&self) -> Severity {
82 self.severity
83 }
84 fn format_regex(&self) -> &Regex {
85 self.compiled_regex()
86 }
87 fn metadata(&self) -> Option<&PatternMetadata> {
88 self.metadata.as_ref()
89 }
90 fn rotation(&self) -> Option<&RotationSpec> {
91 self.rotation.as_ref()
92 }
93 fn liveness(&self) -> Option<&LivenessSpec> {
94 self.liveness.as_ref()
95 }
96}
97
98const fn meta(provider_id: &'static str, retrieval_url_template: &'static str) -> PatternMetadata {
106 PatternMetadata {
107 provider_id: Cow::Borrowed(provider_id),
108 retrieval_url_template: Cow::Borrowed(retrieval_url_template),
109 default_expiry_days: None,
110 scopes_hint: Vec::new(),
111 }
112}
113
114const fn meta_with_expiry(
116 provider_id: &'static str,
117 retrieval_url_template: &'static str,
118 default_expiry_days: u32,
119) -> PatternMetadata {
120 PatternMetadata {
121 provider_id: Cow::Borrowed(provider_id),
122 retrieval_url_template: Cow::Borrowed(retrieval_url_template),
123 default_expiry_days: Some(default_expiry_days),
124 scopes_hint: Vec::new(),
125 }
126}
127
128#[allow(clippy::too_many_lines)]
143pub static BUILTINS: LazyLock<Vec<Builtin>> = LazyLock::new(|| {
144 vec![
145 Builtin {
147 id: "github-pat",
148 display_name: "GitHub Classic Personal Access Token",
149 severity: Severity::High,
150 regex_src: r"^gh[pousr]_[A-Za-z0-9]{36,255}$",
151 regex: OnceLock::new(),
152 metadata: Some(meta_with_expiry(
153 "github",
154 "https://github.com/settings/tokens",
155 90,
156 )),
157 rotation: None,
158 liveness: None,
159 },
160 Builtin {
161 id: "github-fine-grained-pat",
162 display_name: "GitHub Fine-Grained Personal Access Token",
163 severity: Severity::High,
164 regex_src: r"^github_pat_[A-Za-z0-9_]{82,}$",
165 regex: OnceLock::new(),
166 metadata: Some(meta_with_expiry(
167 "github",
168 "https://github.com/settings/personal-access-tokens/new",
169 90,
170 )),
171 rotation: None,
172 liveness: None,
173 },
174 Builtin {
176 id: "gitlab-pat",
177 display_name: "GitLab Personal Access Token",
178 severity: Severity::High,
179 regex_src: r"^glpat-[A-Za-z0-9_-]{20,}$",
180 regex: OnceLock::new(),
181 metadata: Some(meta_with_expiry(
182 "gitlab",
183 "https://gitlab.com/-/profile/personal_access_tokens",
184 90,
185 )),
186 rotation: None,
187 liveness: None,
188 },
189 Builtin {
190 id: "gitlab-deploy-token",
191 display_name: "GitLab Deploy Token",
192 severity: Severity::High,
193 regex_src: r"^gldt-[A-Za-z0-9_-]{20,}$",
194 regex: OnceLock::new(),
195 metadata: Some(meta(
196 "gitlab",
197 "https://gitlab.com/<group-or-project>/-/settings/repository#js-deploy-tokens",
198 )),
199 rotation: None,
200 liveness: None,
201 },
202 Builtin {
204 id: "aws-access-key",
205 display_name: "AWS Access Key ID",
206 severity: Severity::High,
207 regex_src: r"^AKIA[0-9A-Z]{16}$",
208 regex: OnceLock::new(),
209 metadata: Some(meta_with_expiry(
210 "aws",
211 "https://console.aws.amazon.com/iam/home#/security_credentials",
212 90,
213 )),
214 rotation: None,
215 liveness: None,
216 },
217 Builtin {
219 id: "openai-key",
220 display_name: "OpenAI API Key",
221 severity: Severity::High,
222 regex_src: r"^sk-(proj-)?[A-Za-z0-9_-]{20,}$",
223 regex: OnceLock::new(),
224 metadata: Some(meta("openai", "https://platform.openai.com/api-keys")),
225 rotation: None,
226 liveness: None,
227 },
228 Builtin {
229 id: "anthropic-key",
230 display_name: "Anthropic API Key",
231 severity: Severity::High,
232 regex_src: r"^sk-ant-[A-Za-z0-9_-]{20,}$",
233 regex: OnceLock::new(),
234 metadata: Some(meta(
235 "anthropic",
236 "https://console.anthropic.com/settings/keys",
237 )),
238 rotation: None,
239 liveness: None,
240 },
241 Builtin {
242 id: "moonshot-api-key",
243 display_name: "Kimi (Moonshot AI) API Key",
244 severity: Severity::High,
245 regex_src: r"^sk-[A-Za-z0-9]{32,}$",
246 regex: OnceLock::new(),
247 metadata: Some(meta(
248 "moonshot",
249 "https://platform.moonshot.cn/console/api-keys",
250 )),
251 rotation: None,
256 liveness: None,
257 },
258 Builtin {
260 id: "slack-bot-token",
261 display_name: "Slack Bot User Token",
262 severity: Severity::High,
263 regex_src: r"^xoxb-[0-9A-Za-z-]{20,}$",
264 regex: OnceLock::new(),
265 metadata: Some(meta("slack", "https://api.slack.com/apps")),
266 rotation: None,
267 liveness: None,
268 },
269 Builtin {
270 id: "slack-user-token",
271 display_name: "Slack User Token",
272 severity: Severity::High,
273 regex_src: r"^xoxp-[0-9A-Za-z-]{20,}$",
274 regex: OnceLock::new(),
275 metadata: Some(meta("slack", "https://api.slack.com/apps")),
276 rotation: None,
277 liveness: None,
278 },
279 Builtin {
280 id: "slack-app-token",
281 display_name: "Slack App-Level Token",
282 severity: Severity::High,
283 regex_src: r"^xapp-[0-9A-Za-z-]{20,}$",
284 regex: OnceLock::new(),
285 metadata: Some(meta("slack", "https://api.slack.com/apps")),
286 rotation: None,
287 liveness: None,
288 },
289 Builtin {
290 id: "slack-webhook",
291 display_name: "Slack Incoming Webhook",
292 severity: Severity::High,
293 regex_src: r"^https://hooks\.slack\.com/services/T[A-Za-z0-9]{8,}/B[A-Za-z0-9]{8,}/[A-Za-z0-9]{20,}$",
294 regex: OnceLock::new(),
295 metadata: Some(meta("slack", "https://api.slack.com/messaging/webhooks")),
296 rotation: None,
297 liveness: None,
298 },
299 Builtin {
301 id: "discord-webhook",
302 display_name: "Discord Webhook URL",
303 severity: Severity::High,
304 regex_src: r"^https://(discord(app)?\.com)/api/webhooks/[0-9]{17,20}/[A-Za-z0-9_-]{60,}$",
305 regex: OnceLock::new(),
306 metadata: Some(meta(
307 "discord",
308 "https://discord.com/developers/applications",
309 )),
310 rotation: None,
311 liveness: None,
312 },
313 Builtin {
315 id: "stripe-live-secret",
316 display_name: "Stripe Live Secret Key",
317 severity: Severity::High,
318 regex_src: r"^sk_live_[A-Za-z0-9]{24,}$",
319 regex: OnceLock::new(),
320 metadata: Some(meta("stripe", "https://dashboard.stripe.com/apikeys")),
321 rotation: None,
322 liveness: None,
323 },
324 Builtin {
325 id: "stripe-test-secret",
326 display_name: "Stripe Test Secret Key",
327 severity: Severity::High,
328 regex_src: r"^sk_test_[A-Za-z0-9]{24,}$",
329 regex: OnceLock::new(),
330 metadata: Some(meta("stripe", "https://dashboard.stripe.com/test/apikeys")),
331 rotation: None,
332 liveness: None,
333 },
334 Builtin {
335 id: "stripe-publishable",
336 display_name: "Stripe Publishable Key",
337 severity: Severity::Low,
338 regex_src: r"^pk_(live|test)_[A-Za-z0-9]{24,}$",
339 regex: OnceLock::new(),
340 metadata: Some(meta("stripe", "https://dashboard.stripe.com/apikeys")),
341 rotation: None,
342 liveness: None,
343 },
344 Builtin {
346 id: "npm-token",
347 display_name: "npm Authentication Token",
348 severity: Severity::High,
349 regex_src: r"^npm_[A-Za-z0-9]{36,}$",
350 regex: OnceLock::new(),
351 metadata: Some(meta("npm", "https://www.npmjs.com/settings/<user>/tokens")),
352 rotation: None,
353 liveness: None,
354 },
355 Builtin {
356 id: "sendgrid-key",
357 display_name: "SendGrid API Key",
358 severity: Severity::High,
359 regex_src: r"^SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}$",
360 regex: OnceLock::new(),
361 metadata: Some(meta(
362 "sendgrid",
363 "https://app.sendgrid.com/settings/api_keys",
364 )),
365 rotation: None,
366 liveness: None,
367 },
368 Builtin {
369 id: "twilio-account-sid",
370 display_name: "Twilio Account SID",
371 severity: Severity::Medium,
372 regex_src: r"^AC[a-f0-9]{32}$",
373 regex: OnceLock::new(),
374 metadata: Some(meta("twilio", "https://console.twilio.com")),
375 rotation: None,
376 liveness: None,
377 },
378 Builtin {
379 id: "doppler-cli-token",
380 display_name: "Doppler CLI Token",
381 severity: Severity::High,
382 regex_src: r"^dp\.ct\.[A-Za-z0-9]{40,}$",
383 regex: OnceLock::new(),
384 metadata: Some(meta(
385 "doppler",
386 "https://dashboard.doppler.com/workplace/tokens",
387 )),
388 rotation: None,
389 liveness: None,
390 },
391 Builtin {
393 id: "jwt",
394 display_name: "JSON Web Token",
395 severity: Severity::High,
396 regex_src: r"^eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$",
397 regex: OnceLock::new(),
398 metadata: None,
399 rotation: None,
400 liveness: None,
401 },
402 Builtin {
403 id: "private-key-rsa",
404 display_name: "RSA Private Key",
405 severity: Severity::High,
406 regex_src: r"-----BEGIN RSA PRIVATE KEY-----",
407 regex: OnceLock::new(),
408 metadata: None,
409 rotation: None,
410 liveness: None,
411 },
412 Builtin {
413 id: "private-key-openssh",
414 display_name: "OpenSSH Private Key",
415 severity: Severity::High,
416 regex_src: r"-----BEGIN OPENSSH PRIVATE KEY-----",
417 regex: OnceLock::new(),
418 metadata: None,
419 rotation: None,
420 liveness: None,
421 },
422 Builtin {
423 id: "private-key-ec",
424 display_name: "EC Private Key",
425 severity: Severity::High,
426 regex_src: r"-----BEGIN EC PRIVATE KEY-----",
427 regex: OnceLock::new(),
428 metadata: None,
429 rotation: None,
430 liveness: None,
431 },
432 Builtin {
433 id: "private-key-pgp",
434 display_name: "PGP Private Key Block",
435 severity: Severity::High,
436 regex_src: r"-----BEGIN PGP PRIVATE KEY BLOCK-----",
437 regex: OnceLock::new(),
438 metadata: None,
439 rotation: None,
440 liveness: None,
441 },
442 Builtin {
443 id: "private-key-generic",
444 display_name: "Private Key (catch-all)",
445 severity: Severity::High,
446 regex_src: r"-----BEGIN [A-Z ]+PRIVATE KEY( BLOCK)?-----",
447 regex: OnceLock::new(),
448 metadata: None,
449 rotation: None,
450 liveness: None,
451 },
452 Builtin {
454 id: "postgres-url",
455 display_name: "PostgreSQL Connection String with Password",
456 severity: Severity::High,
457 regex_src: r"^postgres(ql)?://[^:/?#\s@]+:[^@/?#\s]+@[^/?#\s:]+(:[0-9]+)?/.+$",
458 regex: OnceLock::new(),
459 metadata: None,
460 rotation: None,
461 liveness: None,
462 },
463 Builtin {
464 id: "mongodb-url",
465 display_name: "MongoDB Connection String with Password",
466 severity: Severity::High,
467 regex_src: r"^mongodb(\+srv)?://[^:/?#\s@]+:[^@/?#\s]+@[^/?#\s]+/.*$",
468 regex: OnceLock::new(),
469 metadata: None,
470 rotation: None,
471 liveness: None,
472 },
473 Builtin {
474 id: "redis-url",
475 display_name: "Redis Connection String with Password",
476 severity: Severity::High,
477 regex_src: r"^rediss?://[^:/?#\s@]*:[^@/?#\s]+@[^/?#\s:]+(:[0-9]+)?(/.*)?$",
478 regex: OnceLock::new(),
479 metadata: None,
480 rotation: None,
481 liveness: None,
482 },
483 Builtin {
484 id: "mysql-url",
485 display_name: "MySQL Connection String with Password",
486 severity: Severity::High,
487 regex_src: r"^mysql://[^:/?#\s@]+:[^@/?#\s]+@[^/?#\s:]+(:[0-9]+)?/.+$",
488 regex: OnceLock::new(),
489 metadata: None,
490 rotation: None,
491 liveness: None,
492 },
493 Builtin {
495 id: "generic-bearer",
496 display_name: "Generic Long Bearer-Style Token (catch-all)",
497 severity: Severity::Low,
498 regex_src: r"^[A-Za-z0-9._-]{40,}$",
499 regex: OnceLock::new(),
500 metadata: None,
501 rotation: None,
502 liveness: None,
503 },
504 ]
505});
506
507pub fn builtins() -> impl Iterator<Item = &'static dyn SecretPattern> {
518 let slice: &'static [Builtin] = &BUILTINS;
522 slice.iter().map(|b| b as &'static dyn SecretPattern)
523}
524
525pub fn find(id: &str) -> Option<&'static dyn SecretPattern> {
531 let slice: &'static [Builtin] = &BUILTINS;
532 slice
533 .iter()
534 .find(|b| b.id == id)
535 .map(|b| b as &'static dyn SecretPattern)
536}
537
538#[cfg(test)]
543mod tests {
544 use super::*;
545
546 const TEST_CASES: &[(&str, &str, &str)] = &[
550 (
552 "github-pat",
553 "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ",
554 "not-a-token",
555 ),
556 (
557 "github-fine-grained-pat",
558 "github_pat_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
562 "github_pat_short",
563 ),
564 ("gitlab-pat", "glpat-abcdefghij_KLMNOPQRSTU", "glpat-short"),
565 (
566 "gitlab-deploy-token",
567 "gldt-abcdefghij_KLMNOPQRSTU",
568 "gldt-short",
569 ),
570 ("aws-access-key", "AKIAIOSFODNN7EXAMPLE", "AKIASHORT"),
571 (
572 "openai-key",
573 "sk-proj-abcdefghijklmnopqrstuvwx",
574 "sk-too-short",
575 ),
576 (
577 "anthropic-key",
578 "sk-ant-api03-abcdefghijklmnopqrst",
579 "sk-not-anthropic",
580 ),
581 (
582 "slack-bot-token",
583 "xoxb-12345-67890-abcdefghijklmno",
584 "xoxb-short",
585 ),
586 (
587 "slack-user-token",
588 "xoxp-12345-67890-abcdefghijklmno",
589 "xoxp-short",
590 ),
591 (
592 "slack-app-token",
593 "xapp-1-A12345-67890-abcdefghijkl",
594 "xapp-short",
595 ),
596 (
597 "slack-webhook",
598 "https://hooks.slack.com/services/T01234567/B01234567/abcdefghijklmnopqrst",
599 "https://hooks.slack.com/services/short",
600 ),
601 (
602 "discord-webhook",
603 "https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ab",
604 "https://discord.com/api/webhooks/short",
605 ),
606 (
614 "stripe-live-secret",
615 concat!("sk_li", "ve_abcdefghijklmnopqrstuvwx"),
616 concat!("sk_li", "ve_short"),
617 ),
618 (
619 "stripe-test-secret",
620 concat!("sk_te", "st_abcdefghijklmnopqrstuvwx"),
621 concat!("sk_te", "st_short"),
622 ),
623 (
624 "stripe-publishable",
625 concat!("pk_li", "ve_abcdefghijklmnopqrstuvwx"),
626 "pk_unknown_x",
627 ),
628 (
629 "npm-token",
630 concat!("npm", "_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"),
631 "npm_short",
632 ),
633 (
634 "sendgrid-key",
635 concat!(
638 "SG",
639 ".abcdefghijklmnopqrstuv.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ"
640 ),
641 "SG.short.x",
642 ),
643 (
644 "twilio-account-sid",
645 concat!("AC", "abcdef0123456789abcdef0123456789"),
646 "ACshort",
647 ),
648 (
649 "doppler-cli-token",
650 concat!("dp.", "ct.abcdefghij0123456789abcdefghij0123456789"),
651 "dp.ct.short",
652 ),
653 (
654 "jwt",
655 "eyJhbGciOiJIUzI1NiIs.eyJzdWIiOiIxMjM0NTY3ODkw.SflKxwRJSMeKKF2QT4f",
656 "eyJonly.eyJtwo",
657 ),
658 (
659 "private-key-rsa",
660 "-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----",
661 "no rsa key here",
662 ),
663 (
664 "private-key-openssh",
665 "-----BEGIN OPENSSH PRIVATE KEY-----\nb3Bl\n-----END OPENSSH PRIVATE KEY-----",
666 "ssh-rsa AAAA",
667 ),
668 (
669 "private-key-ec",
670 "-----BEGIN EC PRIVATE KEY-----\nMHc\n-----END EC PRIVATE KEY-----",
671 "no ec",
672 ),
673 (
674 "private-key-pgp",
675 "-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQOY\n-----END PGP PRIVATE KEY BLOCK-----",
676 "no pgp",
677 ),
678 (
679 "private-key-generic",
680 "-----BEGIN DSA PRIVATE KEY-----",
681 "-----BEGIN PUBLIC KEY-----",
682 ),
683 (
684 "postgres-url",
685 "postgres://user:p4ssw0rd@db.example.test:5432/appdb",
686 "postgres://localhost/appdb",
687 ),
688 (
689 "mongodb-url",
690 "mongodb+srv://user:p4ssw0rd@cluster0.example.test/appdb",
691 "mongodb://localhost/appdb",
692 ),
693 (
694 "redis-url",
695 "redis://:p4ssw0rd@redis.example.test:6379/0",
696 "redis://localhost:6379",
697 ),
698 (
699 "mysql-url",
700 "mysql://user:p4ssw0rd@db.example.test:3306/appdb",
701 "mysql://localhost/appdb",
702 ),
703 (
704 "generic-bearer",
705 "abcdefghijABCDEFGHIJ0123456789_abcdefghij",
706 "tooshort",
707 ),
708 (
709 "moonshot-api-key",
710 "sk-abcdefghijklmnopqrstuvwxyz0123456789",
711 "ghp_xxx",
712 ),
713 ];
714
715 #[test]
716 fn catalogue_has_thirty_patterns() {
717 assert_eq!(BUILTINS.len(), 31);
721 }
722
723 #[test]
724 fn all_builtin_regex_sources_compile() {
725 for b in BUILTINS.iter() {
726 let _ = b.compiled_regex();
730 }
731 }
732
733 #[test]
734 fn pattern_ids_are_unique() {
735 let mut seen = std::collections::HashSet::new();
736 for b in BUILTINS.iter() {
737 assert!(
738 seen.insert(b.id),
739 "duplicate pattern id in catalogue: {}",
740 b.id
741 );
742 }
743 }
744
745 #[test]
746 fn pattern_ids_are_lowercase_kebab() {
747 for b in BUILTINS.iter() {
748 assert!(
749 b.id.chars()
750 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
751 "pattern id '{}' is not lowercase-kebab-case",
752 b.id
753 );
754 }
755 }
756
757 #[test]
758 fn test_cases_cover_every_pattern() {
759 let case_ids: std::collections::HashSet<&str> =
760 TEST_CASES.iter().map(|(id, _, _)| *id).collect();
761 for b in BUILTINS.iter() {
762 assert!(
763 case_ids.contains(b.id),
764 "pattern '{}' is missing a TEST_CASES entry",
765 b.id
766 );
767 }
768 assert_eq!(
769 case_ids.len(),
770 BUILTINS.len(),
771 "duplicate ids in TEST_CASES"
772 );
773 }
774
775 #[test]
776 fn each_pattern_matches_its_positive_sample() {
777 for (id, positive, _negative) in TEST_CASES {
778 let p = find(id).unwrap_or_else(|| panic!("pattern '{id}' not in catalogue"));
779 assert!(
780 p.format_regex().is_match(positive),
781 "pattern '{id}' should match positive sample {positive:?}"
782 );
783 }
784 }
785
786 #[test]
787 fn each_pattern_rejects_its_negative_decoy() {
788 for (id, _positive, negative) in TEST_CASES {
789 let p = find(id).unwrap_or_else(|| panic!("pattern '{id}' not in catalogue"));
790 assert!(
791 !p.format_regex().is_match(negative),
792 "pattern '{id}' should NOT match negative decoy {negative:?}"
793 );
794 }
795 }
796
797 #[test]
798 fn find_returns_none_for_unknown_id() {
799 assert!(find("no-such-pattern").is_none());
800 }
801
802 #[test]
803 fn find_returns_some_for_known_id() {
804 let p = find("github-pat").expect("github-pat must exist");
805 assert_eq!(p.id(), "github-pat");
806 assert_eq!(p.severity(), Severity::High);
807 }
808
809 #[test]
810 fn builtins_iter_yields_every_pattern() {
811 let count = builtins().count();
812 assert_eq!(count, BUILTINS.len());
813 }
814
815 #[test]
816 fn metadata_present_on_provider_patterns() {
817 for b in BUILTINS.iter() {
822 let has_provider = !matches!(
823 b.id,
824 "jwt"
825 | "private-key-rsa"
826 | "private-key-openssh"
827 | "private-key-ec"
828 | "private-key-pgp"
829 | "private-key-generic"
830 | "postgres-url"
831 | "mongodb-url"
832 | "redis-url"
833 | "mysql-url"
834 | "generic-bearer"
835 );
836 if has_provider {
837 assert!(
838 b.metadata.is_some(),
839 "pattern '{}' should carry PatternMetadata",
840 b.id
841 );
842 } else {
843 assert!(
844 b.metadata.is_none(),
845 "pattern '{}' should NOT carry PatternMetadata (it is a generic shape)",
846 b.id
847 );
848 }
849 }
850 }
851
852 #[test]
853 fn rotation_and_liveness_are_unset_in_v1() {
854 for b in BUILTINS.iter() {
857 assert!(b.rotation.is_none());
858 assert!(b.liveness.is_none());
859 }
860 }
861}