1use std::collections::{BTreeSet, HashMap};
2use std::path::PathBuf;
3
4use regex::Regex;
5use serde::Deserialize;
6use thiserror::Error;
7
8use crate::{CollisionMembership, LocaleTag, PiiClass, SafetyTier};
9
10const SUPPORTED_SCHEMA_MAJOR_MINOR: &str = "0.1.";
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct Rulepack {
14 pub schema_version: String,
15 pub rulepack_id: String,
16 pub rulepack_version: String,
17 pub default_locales: Vec<LocaleTag>,
18 pub locale: Option<LocaleData>,
19 pub recognizers: Vec<RecognizerSpec>,
20}
21
22#[derive(Debug, Clone, PartialEq)]
23#[non_exhaustive]
24pub struct RecognizerSpec {
25 pub id: String,
26 pub class: PiiClass,
27 pub cooperates_with: Vec<String>,
28 pub collision: Option<CollisionMembership>,
29 pub enabled: bool,
30 pub safety_tier: SafetyTier,
31 pub locales: Vec<LocaleTag>,
32 pub matcher: RawMatch,
33 pub context: Option<ContextSpec>,
34 pub validator: Option<ValidatorSpec>,
35 pub normalizer: Option<NormalizerSpec>,
36 pub scoring: ScoringSpec,
37 pub token: TokenSpec,
38 pub source: Option<SourceSpec>,
39}
40
41#[derive(Debug, Clone, PartialEq, Deserialize)]
42#[serde(tag = "kind", deny_unknown_fields, rename_all = "snake_case")]
43#[non_exhaustive]
44pub enum RawMatch {
45 Regex {
46 #[serde(default)]
47 pattern: Option<String>,
48 #[serde(default)]
49 pattern_template: Option<String>,
50 #[serde(default)]
51 capture_groups: Option<Vec<u32>>,
52 },
53 Dictionary {
54 #[serde(default)]
55 terms: Vec<String>,
56 #[serde(default)]
57 terms_file: Option<String>,
58 #[serde(default)]
59 terms_from_context: Option<String>,
60 #[serde(default)]
61 case_sensitive: bool,
62 },
63 Ner {
64 model_ref: String,
65 },
66 AnchoredMatch {
67 cues_bucket: String,
68 boundary: String,
69 right_window_chars: u16,
70 name_shape: String,
71 cue_position: String,
72 },
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
76#[serde(deny_unknown_fields, rename_all = "snake_case")]
77#[non_exhaustive]
78pub enum AnchoredBoundary {
79 Punctuation,
80 Whitespace,
81 LineEnd,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
93#[serde(deny_unknown_fields, rename_all = "snake_case")]
94#[non_exhaustive]
95pub enum NameShape {
96 PersonName,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
100#[serde(deny_unknown_fields, rename_all = "snake_case")]
101#[non_exhaustive]
102pub enum CuePosition {
103 Before,
104 After,
105}
106
107#[derive(Debug, Clone, PartialEq)]
108#[non_exhaustive]
109pub struct ContextSpec {
110 pub hotwords: Vec<String>,
111 pub window: Option<u16>,
112 pub boost: Option<f32>,
113 pub exclusions: Vec<String>,
114}
115
116#[derive(Debug, Clone, PartialEq)]
117pub struct ValidatorSpec {
118 pub kind: String,
119}
120
121#[derive(Debug, Clone, PartialEq)]
122pub struct NormalizerSpec {
123 pub kind: String,
124}
125
126#[derive(Debug, Clone, PartialEq)]
127pub struct ScoringSpec {
128 pub base: f32,
129 pub priority: i32,
130}
131
132#[derive(Debug, Clone, PartialEq)]
133#[non_exhaustive]
134pub struct TokenSpec {
135 pub family: Option<String>,
136 pub format: Option<String>,
137}
138
139#[derive(Debug, Clone, PartialEq)]
140pub struct SourceSpec {
141 pub origin: String,
142 pub from: Option<String>,
143 pub license: Option<String>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Default)]
147pub struct LocaleData {
148 pub buckets: HashMap<String, LocaleBucket>,
149 pub cues: HashMap<String, LocaleCueBundle>,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct LocaleBucket {
154 pub names: Vec<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158#[non_exhaustive]
159pub struct LocaleCueBundle {
160 pub names: Vec<String>,
161 pub window_chars: Option<u16>,
162}
163
164impl LocaleCueBundle {
165 pub fn new(names: Vec<String>, window_chars: Option<u16>) -> Self {
167 Self {
168 names,
169 window_chars,
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175#[non_exhaustive]
176pub enum RulepackSource {
177 Embedded(&'static str),
178 Path(PathBuf),
179}
180
181#[derive(Debug, Error)]
182#[non_exhaustive]
183pub enum RulepackError {
184 #[error("failed to read rulepack: {0}")]
185 Io(#[source] std::io::Error),
186 #[error("failed to parse rulepack TOML: {0}")]
187 Toml(#[source] toml::de::Error),
188 #[error("unsupported rulepack schema_version {found}; supported {supported}")]
189 SchemaVersion { found: String, supported: String },
190 #[error("unknown pii class: {0}")]
191 UnknownClass(String),
192 #[error("unknown locale: {0}")]
193 UnknownLocale(String),
194 #[error("unsupported matcher kind: {0}")]
195 UnsupportedMatcher(String),
196 #[error("unsupported anchored_match field '{field}' value '{value}'")]
197 UnsupportedAnchoredMatch { field: String, value: String },
198 #[error("unsupported rulepack field '{field}' in B1; planned for {planned_version}")]
199 UnsupportedField {
200 field: String,
201 planned_version: &'static str,
202 },
203 #[error("unsupported validator kind: {kind}")]
204 UnsupportedValidator { kind: String },
205 #[error("unsupported normalizer kind: {kind}")]
206 UnsupportedNormalizer { kind: String },
207 #[error("unsupported safety_tier: {value}")]
208 UnsupportedSafetyTier { value: String },
209 #[error("unsupported rule spec variant: {variant}")]
210 UnsupportedRuleSpec { variant: String },
211 #[error("duplicate recognizer id '{id}' in rulepacks '{first_pack}' and '{second_pack}'")]
212 DuplicateId {
213 id: String,
214 first_pack: String,
215 second_pack: String,
216 },
217 #[error("regex recognizer '{id}' must define exactly one of pattern or pattern_template")]
218 RegexPatternChoice { id: String },
219 #[error("invalid regex for recognizer '{id}': {source}")]
220 RegexCompile {
221 id: String,
222 #[source]
223 source: regex::Error,
224 },
225 #[error(
226 "regex recognizer '{id}' shadows Gaze token shape sample '{shadowed_shape}' with pattern '{pattern}'"
227 )]
228 TokenShapeShadow {
229 id: String,
230 pattern: String,
231 shadowed_shape: String,
232 },
233 #[error("unknown pattern_template placeholder '{placeholder}' in recognizer '{id}'")]
234 UnknownPatternTemplatePlaceholder { id: String, placeholder: String },
235 #[error(
236 "context class_map override for dictionary '{dict}' changes {old_class:?} to {new_class:?}, but {uncovered_rule}"
237 )]
238 ClassMapOverrideClash {
239 dict: String,
240 old_class: PiiClass,
241 new_class: PiiClass,
242 uncovered_rule: String,
243 },
244 #[error(
245 "same-class recognizers '{recognizer_a}' and '{recognizer_b}' both emit {class:?} but neither declares cooperates_with"
246 )]
247 SameClassWithoutCooperation {
248 class: PiiClass,
249 recognizer_a: String,
250 recognizer_b: String,
251 },
252 #[error(
253 "recognizers {recognizer_ids:?} share class {class:?} with equivalent regex shape and overlapping locale projection {locale_overlap:?}"
254 )]
255 ConflictingLocaleProjection {
256 class: PiiClass,
257 recognizer_ids: Vec<String>,
258 locale_overlap: Vec<LocaleTag>,
259 },
260 #[error("recognizer '{recognizer_id}' has invalid collision {field} '{value}'")]
261 InvalidCollisionName {
262 recognizer_id: String,
263 field: &'static str,
264 value: String,
265 },
266 #[error(
267 "recognizers '{first_recognizer_id}' and '{second_recognizer_id}' declare collision family '{family}' variant '{variant}' with inconsistent precedence {first_precedence} and {second_precedence}"
268 )]
269 InconsistentCollisionVariant {
270 family: String,
271 variant: String,
272 first_recognizer_id: String,
273 first_precedence: u32,
274 second_recognizer_id: String,
275 second_precedence: u32,
276 },
277 #[error(
278 "collision family '{family}' variants '{first_variant}' and '{second_variant}' share ambiguous precedence {precedence}"
279 )]
280 AmbiguousFamilyPrecedence {
281 family: String,
282 first_variant: String,
283 second_variant: String,
284 precedence: u32,
285 },
286}
287
288impl Rulepack {
289 pub fn load(source: RulepackSource) -> Result<Rulepack, RulepackError> {
290 let raw = match source {
291 RulepackSource::Embedded(contents) => contents.to_string(),
292 RulepackSource::Path(path) => {
293 std::fs::read_to_string(path).map_err(RulepackError::Io)?
294 }
295 };
296 Self::parse(&raw)
297 }
298
299 pub fn parse(raw: &str) -> Result<Rulepack, RulepackError> {
300 let (raw, lint) = extract_recognizer_lint_config(raw);
301 let raw: RawRulepack = toml::from_str(&raw).map_err(RulepackError::Toml)?;
302 RawRulepackWithLint { raw, lint }.try_into()
303 }
304
305 pub fn activated_classes(&self) -> BTreeSet<PiiClass> {
306 let mut classes = BTreeSet::new();
307 for recognizer in self
308 .recognizers
309 .iter()
310 .filter(|recognizer| recognizer.enabled)
311 {
312 classes.insert(recognizer.class.clone());
313 if let Some(family_class) = recognizer
314 .collision
315 .as_ref()
316 .and_then(|collision| {
317 collision
318 .mandatory_anchor
319 .as_ref()
320 .map(|_| &collision.family)
321 })
322 .map(|family| PiiClass::Custom(format!("family:{family}")))
323 {
324 classes.insert(family_class);
325 }
326 }
327 classes
328 }
329}
330
331#[derive(Debug, Deserialize)]
332#[serde(deny_unknown_fields)]
333struct RawRulepack {
334 schema_version: String,
335 rulepack_id: String,
336 rulepack_version: String,
337 #[serde(default)]
338 default_locales: Vec<String>,
339 #[serde(default)]
340 locale: Option<RawLocaleData>,
341 #[serde(default)]
342 recognizers: Vec<RawRecognizerSpec>,
343}
344
345#[derive(Debug, Default)]
346struct RawRecognizerLintConfig {
347 strict_locale_overlap: bool,
348}
349
350#[derive(Debug)]
351struct RawRulepackWithLint {
352 raw: RawRulepack,
353 lint: RawRecognizerLintConfig,
354}
355
356#[derive(Debug, Deserialize)]
357struct RawLocaleData {
358 #[serde(default)]
359 cues: HashMap<String, RawLocaleCueBundle>,
360 #[serde(flatten)]
361 buckets: HashMap<String, RawLocaleBucket>,
362}
363
364#[derive(Debug, Deserialize)]
365#[serde(deny_unknown_fields)]
366struct RawLocaleBucket {
367 names: Vec<String>,
368}
369
370#[derive(Debug, Deserialize)]
371#[serde(deny_unknown_fields)]
372struct RawLocaleCueBundle {
373 names: Vec<String>,
374 #[serde(default)]
375 window_chars: Option<u16>,
376}
377
378#[derive(Debug, Deserialize)]
379#[serde(deny_unknown_fields)]
380struct RawRecognizerSpec {
381 id: String,
382 class: String,
383 #[serde(default)]
384 cooperates_with: Vec<String>,
385 #[serde(default)]
386 collision: Option<RawCollisionSpec>,
387 #[serde(default = "default_true")]
388 enabled: bool,
389 #[serde(default)]
390 safety_tier: Option<String>,
391 #[serde(default)]
392 locales: Vec<String>,
393 #[serde(rename = "match")]
394 matcher: RawMatch,
395 #[serde(default)]
396 context: Option<RawContextSpec>,
397 #[serde(default)]
398 validator: Option<RawValidatorSpec>,
399 #[serde(default)]
400 normalizer: Option<RawNormalizerSpec>,
401 #[serde(default)]
402 scoring: Option<RawScoringSpec>,
403 #[serde(default)]
404 token: RawTokenSpec,
405 #[serde(default)]
406 source: Option<RawSourceSpec>,
407}
408
409#[derive(Debug, Deserialize)]
410#[serde(deny_unknown_fields)]
411struct RawContextSpec {
412 #[serde(default)]
413 hotwords: Vec<String>,
414 #[serde(default)]
415 window: Option<u16>,
416 #[serde(default)]
417 boost: Option<f32>,
418 #[serde(default)]
419 exclusions: Vec<String>,
420}
421
422#[derive(Debug, Deserialize)]
423#[serde(deny_unknown_fields)]
424struct RawValidatorSpec {
425 kind: String,
426}
427
428#[derive(Debug, Deserialize)]
429#[serde(deny_unknown_fields)]
430struct RawNormalizerSpec {
431 kind: String,
432}
433
434#[derive(Debug, Deserialize)]
435#[serde(deny_unknown_fields)]
436struct RawScoringSpec {
437 #[serde(default = "default_base_score")]
438 base: f32,
439 #[serde(default)]
440 priority: i32,
441}
442
443#[derive(Debug, Clone, Deserialize)]
444#[serde(deny_unknown_fields)]
445pub(crate) struct RawCollisionSpec {
446 pub(crate) family: String,
447 pub(crate) variant: String,
448 #[serde(default = "default_collision_precedence")]
449 pub(crate) precedence: u32,
450 #[serde(default)]
451 pub(crate) mandatory_anchor: Option<String>,
452}
453
454#[derive(Debug, Default, Deserialize)]
455#[serde(deny_unknown_fields)]
456struct RawTokenSpec {
457 #[serde(default)]
458 family: Option<String>,
459 #[serde(default)]
460 format: Option<String>,
461}
462
463#[derive(Debug, Deserialize)]
464#[serde(deny_unknown_fields)]
465struct RawSourceSpec {
466 origin: String,
467 #[serde(default)]
468 from: Option<String>,
469 #[serde(default)]
470 license: Option<String>,
471}
472
473impl TryFrom<RawRulepack> for Rulepack {
474 type Error = RulepackError;
475
476 fn try_from(raw: RawRulepack) -> Result<Self, Self::Error> {
477 RawRulepackWithLint {
478 raw,
479 lint: RawRecognizerLintConfig::default(),
480 }
481 .try_into()
482 }
483}
484
485impl TryFrom<RawRulepackWithLint> for Rulepack {
486 type Error = RulepackError;
487
488 fn try_from(raw_with_lint: RawRulepackWithLint) -> Result<Self, Self::Error> {
489 let raw = raw_with_lint.raw;
490 if !raw.schema_version.starts_with(SUPPORTED_SCHEMA_MAJOR_MINOR) {
491 return Err(RulepackError::SchemaVersion {
492 found: raw.schema_version,
493 supported: "~0.1.x".to_string(),
494 });
495 }
496
497 let default_locales = parse_locales(raw.default_locales)?;
498 let mut recognizers = raw
499 .recognizers
500 .into_iter()
501 .map(|recognizer| parse_recognizer(recognizer, &default_locales))
502 .collect::<Result<Vec<_>, _>>()?;
503 validate_collision_memberships(&recognizers)?;
504 apply_collision_family_cooperation(&mut recognizers);
505 validate_rulepack_recognizers(&recognizers, &default_locales, &raw_with_lint.lint)?;
506 let locale = raw.locale.map(LocaleData::from);
507 reject_anchored_match_ellipsis_cues(&recognizers, locale.as_ref())?;
508
509 Ok(Self {
510 schema_version: raw.schema_version,
511 rulepack_id: raw.rulepack_id,
512 rulepack_version: raw.rulepack_version,
513 default_locales,
514 locale,
515 recognizers,
516 })
517 }
518}
519
520fn extract_recognizer_lint_config(raw: &str) -> (String, RawRecognizerLintConfig) {
521 let mut sanitized = String::with_capacity(raw.len());
522 let mut lint = RawRecognizerLintConfig::default();
523 let mut in_lint = false;
524
525 for line in raw.lines() {
526 let trimmed = line.trim();
527 if trimmed == "[recognizers.lint]" {
528 in_lint = true;
529 continue;
530 }
531 if in_lint && trimmed.starts_with('[') {
532 in_lint = false;
533 }
534 if in_lint {
535 if let Some((key, value)) = trimmed.split_once('=') {
536 if key.trim() == "strict_locale_overlap" {
537 lint.strict_locale_overlap = value.trim().eq_ignore_ascii_case("true");
538 }
539 }
540 continue;
541 }
542 sanitized.push_str(line);
543 sanitized.push('\n');
544 }
545
546 (sanitized, lint)
547}
548
549impl From<RawLocaleData> for LocaleData {
550 fn from(raw: RawLocaleData) -> Self {
551 Self {
552 buckets: raw
553 .buckets
554 .into_iter()
555 .map(|(name, bucket)| {
556 (
557 name,
558 LocaleBucket {
559 names: bucket.names,
560 },
561 )
562 })
563 .collect(),
564 cues: raw
565 .cues
566 .into_iter()
567 .map(|(name, bundle)| {
568 (
569 name,
570 LocaleCueBundle::new(bundle.names, bundle.window_chars),
571 )
572 })
573 .collect(),
574 }
575 }
576}
577
578fn parse_recognizer(
579 raw: RawRecognizerSpec,
580 default_locales: &[LocaleTag],
581) -> Result<RecognizerSpec, RulepackError> {
582 reject_unshipped_fields(&raw)?;
583 validate_matcher(&raw)?;
584 let locales = if raw.locales.is_empty() {
585 default_locales.to_vec()
586 } else {
587 parse_locales(raw.locales)?
588 };
589 let collision = raw
590 .collision
591 .map(|collision| parse_collision_membership(&raw.id, collision))
592 .transpose()?;
593 let safety_tier = raw
594 .safety_tier
595 .as_deref()
596 .map(SafetyTier::parse)
597 .transpose()
598 .map_err(|err| RulepackError::UnsupportedSafetyTier {
599 value: err.value().to_string(),
600 })?
601 .unwrap_or_default();
602
603 Ok(RecognizerSpec {
604 id: raw.id,
605 class: parse_class(&raw.class)?,
606 cooperates_with: raw.cooperates_with,
607 collision,
608 enabled: raw.enabled,
609 safety_tier,
610 locales,
611 matcher: raw.matcher,
612 context: raw.context.map(|context| ContextSpec {
613 hotwords: context.hotwords,
614 window: context.window,
615 boost: context.boost,
616 exclusions: context.exclusions,
617 }),
618 validator: raw.validator.map(|validator| ValidatorSpec {
619 kind: validator.kind,
620 }),
621 normalizer: raw.normalizer.map(|normalizer| NormalizerSpec {
622 kind: normalizer.kind,
623 }),
624 scoring: raw.scoring.map_or_else(
625 || ScoringSpec {
626 base: default_base_score(),
627 priority: 0,
628 },
629 |scoring| ScoringSpec {
630 base: scoring.base,
631 priority: scoring.priority,
632 },
633 ),
634 token: TokenSpec {
635 family: raw.token.family,
636 format: raw.token.format,
637 },
638 source: raw.source.map(|source| SourceSpec {
639 origin: source.origin,
640 from: source.from,
641 license: source.license,
642 }),
643 })
644}
645
646pub(crate) fn parse_collision_membership(
647 recognizer_id: &str,
648 raw: RawCollisionSpec,
649) -> Result<CollisionMembership, RulepackError> {
650 validate_collision_name(recognizer_id, "family", &raw.family)?;
651 validate_collision_name(recognizer_id, "variant", &raw.variant)?;
652 Ok(CollisionMembership::new(
653 raw.family,
654 raw.variant,
655 raw.precedence,
656 raw.mandatory_anchor,
657 ))
658}
659
660fn validate_collision_name(
661 recognizer_id: &str,
662 field: &'static str,
663 value: &str,
664) -> Result<(), RulepackError> {
665 let valid = !value.is_empty()
666 && value.len() <= 64
667 && value
668 .bytes()
669 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
670 if valid {
671 Ok(())
672 } else {
673 Err(RulepackError::InvalidCollisionName {
674 recognizer_id: recognizer_id.to_string(),
675 field,
676 value: value.to_string(),
677 })
678 }
679}
680
681fn validate_collision_memberships(recognizers: &[RecognizerSpec]) -> Result<(), RulepackError> {
682 let mut variant_precedence: HashMap<(&str, &str), (&str, u32)> = HashMap::new();
683 let mut family_precedence: HashMap<(&str, u32), &str> = HashMap::new();
684
685 for recognizer in recognizers {
686 let Some(collision) = &recognizer.collision else {
687 continue;
688 };
689
690 if let Some((first_id, first_precedence)) =
691 variant_precedence.get(&(collision.family.as_str(), collision.variant.as_str()))
692 {
693 if *first_precedence != collision.precedence {
694 return Err(RulepackError::InconsistentCollisionVariant {
695 family: collision.family.clone(),
696 variant: collision.variant.clone(),
697 first_recognizer_id: (*first_id).to_string(),
698 first_precedence: *first_precedence,
699 second_recognizer_id: recognizer.id.clone(),
700 second_precedence: collision.precedence,
701 });
702 }
703 } else {
704 variant_precedence.insert(
705 (collision.family.as_str(), collision.variant.as_str()),
706 (recognizer.id.as_str(), collision.precedence),
707 );
708 }
709
710 if let Some(first_variant) =
711 family_precedence.get(&(collision.family.as_str(), collision.precedence))
712 {
713 if *first_variant != collision.variant {
714 return Err(RulepackError::AmbiguousFamilyPrecedence {
715 family: collision.family.clone(),
716 first_variant: (*first_variant).to_string(),
717 second_variant: collision.variant.clone(),
718 precedence: collision.precedence,
719 });
720 }
721 } else {
722 family_precedence.insert(
723 (collision.family.as_str(), collision.precedence),
724 collision.variant.as_str(),
725 );
726 }
727 }
728
729 Ok(())
730}
731
732fn apply_collision_family_cooperation(recognizers: &mut [RecognizerSpec]) {
733 let mut family_members: HashMap<String, Vec<String>> = HashMap::new();
734 for recognizer in recognizers.iter() {
735 let Some(collision) = &recognizer.collision else {
736 continue;
737 };
738 family_members
739 .entry(collision.family.clone())
740 .or_default()
741 .push(recognizer.id.clone());
742 }
743
744 for recognizer in recognizers {
745 let Some(collision) = &recognizer.collision else {
746 continue;
747 };
748 let Some(siblings) = family_members.get(&collision.family) else {
749 continue;
750 };
751 for sibling in siblings {
752 if sibling != &recognizer.id && !recognizer.cooperates_with.contains(sibling) {
753 recognizer.cooperates_with.push(sibling.clone());
754 }
755 }
756 }
757}
758
759fn validate_matcher(raw: &RawRecognizerSpec) -> Result<(), RulepackError> {
760 match &raw.matcher {
761 RawMatch::Regex {
762 pattern,
763 pattern_template,
764 ..
765 } => {
766 if pattern.is_some() == pattern_template.is_some() {
767 return Err(RulepackError::RegexPatternChoice { id: raw.id.clone() });
768 }
769 if let Some(pattern) = pattern {
770 let compiled =
771 Regex::new(pattern).map_err(|source| RulepackError::RegexCompile {
772 id: raw.id.clone(),
773 source,
774 })?;
775 crate::token_shape::reject_if_shadows_token_shape(&compiled, &raw.id).map_err(
776 |shadow| RulepackError::TokenShapeShadow {
777 id: shadow.recognizer_id,
778 pattern: shadow.offending_pattern,
779 shadowed_shape: shadow.shadowed_shape,
780 },
781 )?;
782 }
783 }
784 RawMatch::AnchoredMatch {
785 cues_bucket,
786 boundary,
787 right_window_chars,
788 name_shape,
789 cue_position,
790 ..
791 } => {
792 if cues_bucket.trim().is_empty() {
793 return Err(RulepackError::UnsupportedAnchoredMatch {
794 field: "cues_bucket".to_string(),
795 value: cues_bucket.clone(),
796 });
797 }
798 if !(1..=512).contains(right_window_chars) {
799 return Err(RulepackError::UnsupportedAnchoredMatch {
800 field: "right_window_chars".to_string(),
801 value: right_window_chars.to_string(),
802 });
803 }
804 if !matches!(boundary.as_str(), "punctuation" | "whitespace" | "line_end") {
805 return Err(RulepackError::UnsupportedAnchoredMatch {
806 field: "boundary".to_string(),
807 value: boundary.clone(),
808 });
809 }
810 if name_shape != "person_name" {
811 return Err(RulepackError::UnsupportedAnchoredMatch {
812 field: "name_shape".to_string(),
813 value: name_shape.clone(),
814 });
815 }
816 if !matches!(cue_position.as_str(), "before" | "after") {
817 return Err(RulepackError::UnsupportedAnchoredMatch {
818 field: "cue_position".to_string(),
819 value: cue_position.clone(),
820 });
821 }
822 }
823 RawMatch::Dictionary { .. } | RawMatch::Ner { .. } => {}
824 }
825 Ok(())
826}
827
828fn reject_anchored_match_ellipsis_cues(
829 recognizers: &[RecognizerSpec],
830 locale: Option<&LocaleData>,
831) -> Result<(), RulepackError> {
832 let Some(locale) = locale else {
833 return Ok(());
834 };
835 for recognizer in recognizers {
836 let RawMatch::AnchoredMatch { cues_bucket, .. } = &recognizer.matcher else {
837 continue;
838 };
839 let Some(bucket) = locale.buckets.get(cues_bucket) else {
840 continue;
841 };
842 if let Some(cue) = bucket.names.iter().find(|cue| cue.contains("...")) {
843 return Err(RulepackError::UnsupportedAnchoredMatch {
844 field: format!("locale.{cues_bucket}.names"),
845 value: cue.clone(),
846 });
847 }
848 }
849 Ok(())
850}
851
852fn reject_unshipped_fields(raw: &RawRecognizerSpec) -> Result<(), RulepackError> {
853 const PLANNED_VERSION: &str = "v0.4.1";
854
855 if raw
856 .token
857 .format
858 .as_deref()
859 .is_some_and(|value| !value.is_empty())
860 {
861 return Err(RulepackError::UnsupportedField {
862 field: "token.format".to_string(),
863 planned_version: PLANNED_VERSION,
864 });
865 }
866 if let Some(context) = &raw.context {
867 if !context.hotwords.is_empty() {
868 return Err(RulepackError::UnsupportedField {
869 field: "context.hotwords".to_string(),
870 planned_version: PLANNED_VERSION,
871 });
872 }
873 if context.boost.is_some() {
874 return Err(RulepackError::UnsupportedField {
875 field: "context.boost".to_string(),
876 planned_version: PLANNED_VERSION,
877 });
878 }
879 if context.window.is_some() {
880 return Err(RulepackError::UnsupportedField {
881 field: "context.window".to_string(),
882 planned_version: PLANNED_VERSION,
883 });
884 }
885 }
886 Ok(())
887}
888
889pub fn recognizer_composition_validator(
890 recognizers: &[RecognizerSpec],
891) -> Result<(), RulepackError> {
892 for (index, first) in recognizers.iter().enumerate() {
893 for second in recognizers.iter().skip(index + 1) {
894 if first.class != second.class {
895 continue;
896 }
897 if first.cooperates_with.iter().any(|id| id == &second.id)
898 || second.cooperates_with.iter().any(|id| id == &first.id)
899 {
900 continue;
901 }
902 return Err(RulepackError::SameClassWithoutCooperation {
903 class: first.class.clone(),
904 recognizer_a: first.id.clone(),
905 recognizer_b: second.id.clone(),
906 });
907 }
908 }
909 Ok(())
910}
911
912fn validate_rulepack_recognizers(
913 recognizers: &[RecognizerSpec],
914 active_locales: &[LocaleTag],
915 lint: &RawRecognizerLintConfig,
916) -> Result<(), RulepackError> {
917 recognizer_composition_validator(recognizers)?;
918 lint_locale_projection_collisions(recognizers, active_locales, lint)?;
919 lint_global_naked_patterns(recognizers);
920 Ok(())
921}
922
923fn lint_locale_projection_collisions(
924 recognizers: &[RecognizerSpec],
925 active_locales: &[LocaleTag],
926 lint: &RawRecognizerLintConfig,
927) -> Result<(), RulepackError> {
928 for (index, first) in recognizers.iter().enumerate() {
929 if !first.enabled {
930 continue;
931 }
932 let Some(first_shape) = regex_structural_shape(&first.matcher) else {
933 continue;
934 };
935 if !is_truly_naked_numeric(&first.matcher) {
936 continue;
937 }
938 let first_projection = locale_projection(&first.locales, active_locales);
939 if first_projection.is_empty() {
940 continue;
941 }
942
943 for second in recognizers.iter().skip(index + 1) {
944 if !second.enabled || first.class != second.class {
945 continue;
946 }
947 if !is_truly_naked_numeric(&second.matcher) {
948 continue;
949 }
950 if regex_structural_shape(&second.matcher).as_ref() != Some(&first_shape) {
951 continue;
952 }
953 let second_projection = locale_projection(&second.locales, active_locales);
954 if second_projection.is_empty() {
955 continue;
956 }
957
958 let recognizer_ids = vec![first.id.clone(), second.id.clone()];
959 let locale_overlap = merged_locale_projection(&first_projection, &second_projection);
960 if lint.strict_locale_overlap {
961 return Err(RulepackError::ConflictingLocaleProjection {
962 class: first.class.clone(),
963 recognizer_ids,
964 locale_overlap,
965 });
966 }
967 tracing::warn!(
968 class = %first.class.class_name(),
969 recognizer_ids = ?recognizer_ids,
970 locale_overlap = ?locale_overlap,
971 "recognizers share class with naked-shape regex and non-disjoint locale projection"
972 );
973 }
974 }
975 Ok(())
976}
977
978fn lint_global_naked_patterns(recognizers: &[RecognizerSpec]) {
979 for recognizer in recognizers {
980 if !recognizer.enabled || recognizer.locales != [LocaleTag::Global] {
981 continue;
982 }
983 let Some(shape) = regex_structural_shape(&recognizer.matcher) else {
984 continue;
985 };
986 let RawMatch::Regex {
987 pattern: Some(pattern),
988 ..
989 } = &recognizer.matcher
990 else {
991 continue;
992 };
993 if shape.minimum_match_len < 6 && !has_regex_separator(pattern) {
994 tracing::warn!(
995 recognizer_id = %recognizer.id,
996 class = %recognizer.class.class_name(),
997 minimum_match_len = shape.minimum_match_len,
998 "global recognizer uses short naked regex shape"
999 );
1000 }
1001 }
1002}
1003
1004#[derive(Debug, Clone, PartialEq, Eq)]
1005struct RegexStructuralShape {
1006 minimum_match_len: usize,
1007 character_class: RegexCharacterClass,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq)]
1011enum RegexCharacterClass {
1012 Digit,
1013}
1014
1015fn regex_structural_shape(matcher: &RawMatch) -> Option<RegexStructuralShape> {
1016 let RawMatch::Regex {
1017 pattern: Some(pattern),
1018 pattern_template: None,
1019 ..
1020 } = matcher
1021 else {
1022 return None;
1023 };
1024 if has_unescaped_line_anchor(pattern) {
1025 return None;
1026 }
1027 digit_quantifier_minimum(pattern).map(|minimum_match_len| RegexStructuralShape {
1028 minimum_match_len,
1029 character_class: RegexCharacterClass::Digit,
1030 })
1031}
1032
1033fn is_truly_naked_numeric(matcher: &RawMatch) -> bool {
1034 let RawMatch::Regex {
1035 pattern: Some(pattern),
1036 ..
1037 } = matcher
1038 else {
1039 return false;
1040 };
1041
1042 let mut chars = pattern.chars();
1043 while let Some(ch) = chars.next() {
1044 if ch == '\\' {
1045 chars.next();
1046 continue;
1047 }
1048 if ch.is_ascii_alphabetic() {
1049 return false;
1050 }
1051 }
1052 true
1053}
1054
1055fn has_unescaped_line_anchor(pattern: &str) -> bool {
1056 let mut escaped = false;
1057 let mut in_class = false;
1058 for ch in pattern.chars() {
1059 if escaped {
1060 escaped = false;
1061 continue;
1062 }
1063 match ch {
1064 '\\' => escaped = true,
1065 '[' => in_class = true,
1066 ']' => in_class = false,
1067 '^' | '$' if !in_class => return true,
1068 _ => {}
1069 }
1070 }
1071 false
1072}
1073
1074fn digit_quantifier_minimum(pattern: &str) -> Option<usize> {
1075 find_digit_quantifier(pattern, r"\d{")
1076 .or_else(|| find_digit_quantifier(pattern, "[0-9]{"))
1077 .or_else(|| find_digit_quantifier(pattern, "[[:digit:]]{"))
1078}
1079
1080fn find_digit_quantifier(pattern: &str, needle: &str) -> Option<usize> {
1081 let start = pattern.find(needle)? + needle.len();
1082 let rest = &pattern[start..];
1083 let digits = rest
1084 .chars()
1085 .take_while(|ch| ch.is_ascii_digit())
1086 .collect::<String>();
1087 if digits.is_empty() {
1088 return None;
1089 }
1090 digits.parse().ok()
1091}
1092
1093fn locale_projection(locales: &[LocaleTag], active_locales: &[LocaleTag]) -> Vec<LocaleTag> {
1094 let mut projection = Vec::new();
1095 for locale in locales {
1096 if *locale == LocaleTag::Global {
1097 projection.push(LocaleTag::Global);
1098 } else if active_locales.iter().any(|active| active == locale) {
1099 projection.push(locale.clone());
1100 }
1101 }
1102 projection
1103}
1104
1105fn merged_locale_projection(left: &[LocaleTag], right: &[LocaleTag]) -> Vec<LocaleTag> {
1106 let mut merged = Vec::new();
1107 for locale in left.iter().chain(right) {
1108 if !merged.iter().any(|existing| existing == locale) {
1109 merged.push(locale.clone());
1110 }
1111 }
1112 merged
1113}
1114
1115fn has_regex_separator(pattern: &str) -> bool {
1116 pattern.contains('-')
1117 || pattern.contains('/')
1118 || pattern.contains('.')
1119 || pattern.contains('+')
1120 || pattern.contains("\\s")
1121 || pattern.contains("[:space:]")
1122}
1123
1124pub fn parse_class(input: &str) -> Result<PiiClass, RulepackError> {
1125 let trimmed = input.trim();
1126 let lower = trimmed.to_ascii_lowercase();
1127 match lower.as_str() {
1128 "email" => Ok(PiiClass::Email),
1129 "name" => Ok(PiiClass::Name),
1130 "location" => Ok(PiiClass::Location),
1131 "organization" => Ok(PiiClass::Organization),
1132 custom if custom.starts_with("custom:") => {
1133 let name = trimmed
1134 .split_once(':')
1135 .map(|(_, name)| name)
1136 .unwrap_or_default();
1137 if name.trim().is_empty() {
1138 return Err(RulepackError::UnknownClass(input.to_string()));
1139 }
1140 if name.starts_with("family:") {
1141 Ok(PiiClass::Custom(name.to_string()))
1142 } else {
1143 Ok(PiiClass::custom(name))
1144 }
1145 }
1146 _ => Err(RulepackError::UnknownClass(input.to_string())),
1147 }
1148}
1149
1150fn parse_locales(locales: Vec<String>) -> Result<Vec<LocaleTag>, RulepackError> {
1151 locales
1152 .into_iter()
1153 .map(|locale| {
1154 LocaleTag::parse(&locale).map_err(|_| RulepackError::UnknownLocale(locale.clone()))
1155 })
1156 .collect()
1157}
1158
1159fn default_true() -> bool {
1160 true
1161}
1162
1163fn default_base_score() -> f32 {
1164 0.70
1165}
1166
1167fn default_collision_precedence() -> u32 {
1168 100
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173 use super::*;
1174
1175 const CORE: &str = r#"
1176schema_version = "0.1.0"
1177rulepack_id = "gaze-core"
1178rulepack_version = "0.4.0"
1179default_locales = ["global"]
1180
1181[locale.email_headers]
1182names = ["From", "To", "Cc", "Bcc", "Reply-To", "Sender"]
1183
1184[[recognizers]]
1185id = "email.global"
1186class = "Email"
1187enabled = true
1188locales = ["global"]
1189
1190[recognizers.match]
1191kind = "regex"
1192pattern = '''(?i)(?-u:\b)[a-z0-9_][a-z0-9._%+\-]*@(?:(?:[a-z0-9\-]+\.)*example\.invalid|test\.local|(?:[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?\.)+(?:[a-z]{2,3}|[a-su-z][a-z]{3}|t[a-df-z][a-z]{2}|te[a-rt-z][a-z]|tes[a-su-z]|[a-z]{5,6}|[a-hj-z][a-z]{6}|i[a-mo-z][a-z]{5}|in[a-uw-z][a-z]{4}|inv[b-z][a-z]{3}|inva[a-km-z][a-z]{2}|inval[a-hj-z][a-z]|invali[a-ce-z]|[a-z]{8,}))(?-u:\b)'''
1193
1194[recognizers.context]
1195exclusions = ["test.local"]
1196
1197[recognizers.validator]
1198kind = "email_rfc"
1199
1200[recognizers.normalizer]
1201kind = "email_canonical"
1202
1203[recognizers.scoring]
1204base = 0.70
1205priority = 90
1206
1207[recognizers.token]
1208
1209[recognizers.source]
1210origin = "ported"
1211from = "presidio"
1212license = "Apache-2.0"
1213"#;
1214
1215 #[test]
1216 fn parses_core_rulepack_end_to_end() {
1217 let rulepack = Rulepack::parse(CORE).expect("core rulepack");
1218
1219 assert_eq!(rulepack.rulepack_id, "gaze-core");
1220 assert_eq!(rulepack.default_locales, vec![LocaleTag::Global]);
1221 let header_names = &rulepack
1222 .locale
1223 .as_ref()
1224 .and_then(|locale| locale.buckets.get("email_headers"))
1225 .expect("email headers")
1226 .names;
1227 assert_eq!(
1228 header_names,
1229 &vec!["From", "To", "Cc", "Bcc", "Reply-To", "Sender"]
1230 );
1231 assert_eq!(rulepack.recognizers.len(), 1);
1232 let recognizer = &rulepack.recognizers[0];
1233 assert_eq!(recognizer.id, "email.global");
1234 assert_eq!(recognizer.class, PiiClass::Email);
1235 assert_eq!(recognizer.scoring.priority, 90);
1236 assert!(matches!(recognizer.matcher, RawMatch::Regex { .. }));
1237 }
1238
1239 #[test]
1240 fn parses_nested_locale_cue_bundles() {
1241 let rulepack = Rulepack::parse(
1242 r#"
1243schema_version = "0.1.0"
1244rulepack_id = "locale-cues"
1245rulepack_version = "0.7.0"
1246default_locales = ["en-US"]
1247
1248[locale.forward_markers]
1249names = ["Forwarded message"]
1250
1251[locale.cues.iban]
1252names = ["IBAN:", "Account"]
1253window_chars = 48
1254"#,
1255 )
1256 .expect("locale cues rulepack");
1257
1258 let locale = rulepack.locale.expect("locale data");
1259 assert_eq!(
1260 locale.buckets["forward_markers"].names,
1261 vec!["Forwarded message"]
1262 );
1263 assert_eq!(locale.cues["iban"].names, vec!["IBAN:", "Account"]);
1264 assert_eq!(locale.cues["iban"].window_chars, Some(48));
1265 }
1266
1267 #[test]
1268 fn locale_cue_bundle_constructor_builds_public_type() {
1269 let bundle = LocaleCueBundle::new(vec!["IBAN".to_string()], Some(64));
1270
1271 assert_eq!(bundle.names, vec!["IBAN"]);
1272 assert_eq!(bundle.window_chars, Some(64));
1273 }
1274
1275 #[cfg(feature = "bundled-recognizers")]
1276 #[test]
1277 fn embedded_core_activated_classes_match_rulepack_classes() {
1278 let rulepack = Rulepack::load(RulepackSource::Embedded(
1279 gaze_recognizers::embedded("core").expect("core rulepack"),
1280 ))
1281 .expect("embedded core rulepack");
1282
1283 assert_eq!(
1284 rulepack.activated_classes(),
1285 BTreeSet::from([
1286 PiiClass::Email,
1287 PiiClass::Name,
1288 PiiClass::custom("phone"),
1289 PiiClass::custom("iban"),
1290 PiiClass::Custom("family:payment-card-or-iban".to_string()),
1291 PiiClass::custom("credit_card"),
1292 PiiClass::custom("ip_address"),
1293 PiiClass::custom("eth_address"),
1294 PiiClass::custom("aadhaar"),
1295 PiiClass::custom("nir"),
1296 PiiClass::custom("steuer_id"),
1297 PiiClass::custom("vat_id"),
1298 PiiClass::custom("bsn"),
1299 PiiClass::custom("cpf"),
1300 PiiClass::custom("cnpj"),
1301 PiiClass::custom("nhs_number"),
1302 PiiClass::custom("ssn"),
1303 PiiClass::custom("nino"),
1304 PiiClass::custom("pan"),
1305 PiiClass::custom("postal_code"),
1306 ])
1307 );
1308 }
1309
1310 #[cfg(feature = "bundled-recognizers")]
1311 #[test]
1312 fn embedded_core_loads_full_name_recognizer_cooperation_matrix() {
1313 let rulepack = Rulepack::load(RulepackSource::Embedded(
1314 gaze_recognizers::embedded("core").expect("core rulepack"),
1315 ))
1316 .expect("embedded core rulepack");
1317 let name_recognizers = rulepack
1318 .recognizers
1319 .iter()
1320 .filter(|recognizer| recognizer.class == PiiClass::Name)
1321 .collect::<Vec<_>>();
1322
1323 assert_eq!(name_recognizers.len(), 5);
1324 for recognizer in &name_recognizers {
1325 for peer in &name_recognizers {
1326 if recognizer.id == peer.id {
1327 continue;
1328 }
1329 assert!(
1330 recognizer.cooperates_with.contains(&peer.id),
1331 "{} missing cooperates_with {}",
1332 recognizer.id,
1333 peer.id
1334 );
1335 }
1336 }
1337 }
1338
1339 #[cfg(feature = "bundled-recognizers")]
1340 #[test]
1341 fn embedded_core_extended_alias_activated_classes_match_core() {
1342 let rulepack = Rulepack::load(RulepackSource::Embedded(
1343 gaze_recognizers::embedded("core-extended").expect("core-extended rulepack"),
1344 ))
1345 .expect("embedded core-extended rulepack");
1346 let core = Rulepack::load(RulepackSource::Embedded(
1347 gaze_recognizers::embedded("core").expect("core rulepack"),
1348 ))
1349 .expect("embedded core rulepack");
1350
1351 assert_eq!(rulepack.activated_classes(), core.activated_classes());
1352 }
1353
1354 #[cfg(feature = "bundled-recognizers")]
1355 #[test]
1356 fn activated_classes_include_new_rulepack_recognizer_class() {
1357 let raw = format!(
1358 r#"{}
1359
1360[[recognizers]]
1361id = "test.only"
1362class = "custom:test_only"
1363enabled = true
1364locales = ["global"]
1365
1366[recognizers.match]
1367kind = "regex"
1368pattern = "TEST_ONLY"
1369
1370[recognizers.scoring]
1371base = 0.70
1372priority = 1
1373"#,
1374 gaze_recognizers::embedded("core-extended").expect("core-extended rulepack")
1375 );
1376 let rulepack = Rulepack::parse(&raw).expect("core-extended with synthetic recognizer");
1377
1378 assert!(
1379 rulepack
1380 .activated_classes()
1381 .contains(&PiiClass::custom("test_only")),
1382 "new recognizer class must be derived from rulepack data"
1383 );
1384 }
1385
1386 #[test]
1387 fn rulepack_accepts_token_family() {
1388 let rulepack = Rulepack::parse(&unsupported_field_rulepack(
1389 "[recognizers.token]\nfamily = \"email.formatpreserve\"\n",
1390 ))
1391 .expect("token family is active in v0.4.1");
1392
1393 assert_eq!(
1394 rulepack.recognizers[0].token.family.as_deref(),
1395 Some("email.formatpreserve")
1396 );
1397 }
1398
1399 #[test]
1400 fn rulepack_accepts_collision_membership_and_extends_family_cooperation() {
1401 let rulepack = Rulepack::parse(&collision_rulepack(
1402 r#"
1403[recognizers.collision]
1404family = "tenant-document"
1405variant = "alpha"
1406precedence = 10
1407"#,
1408 r#"
1409[recognizers.collision]
1410family = "tenant-document"
1411variant = "beta"
1412precedence = 20
1413"#,
1414 ))
1415 .expect("collision membership should parse");
1416
1417 assert_eq!(
1418 rulepack.recognizers[0].collision,
1419 Some(CollisionMembership::new(
1420 "tenant-document",
1421 "alpha",
1422 10,
1423 None,
1424 ))
1425 );
1426 assert_eq!(rulepack.recognizers[0].cooperates_with, vec!["tenant.beta"]);
1427 assert_eq!(
1428 rulepack.recognizers[1].cooperates_with,
1429 vec!["tenant.alpha"]
1430 );
1431 }
1432
1433 #[test]
1434 fn rulepack_rejects_inconsistent_collision_variant_precedence() {
1435 let err = Rulepack::parse(&collision_rulepack(
1436 r#"
1437[recognizers.collision]
1438family = "tenant-document"
1439variant = "alpha"
1440precedence = 10
1441"#,
1442 r#"
1443[recognizers.collision]
1444family = "tenant-document"
1445variant = "alpha"
1446precedence = 20
1447"#,
1448 ))
1449 .expect_err("same variant must use one precedence");
1450
1451 assert!(matches!(
1452 err,
1453 RulepackError::InconsistentCollisionVariant {
1454 family,
1455 variant,
1456 first_precedence: 10,
1457 second_precedence: 20,
1458 ..
1459 } if family == "tenant-document" && variant == "alpha"
1460 ));
1461 }
1462
1463 #[test]
1464 fn rulepack_rejects_ambiguous_family_precedence() {
1465 let err = Rulepack::parse(&collision_rulepack(
1466 r#"
1467[recognizers.collision]
1468family = "tenant-document"
1469variant = "alpha"
1470precedence = 10
1471"#,
1472 r#"
1473[recognizers.collision]
1474family = "tenant-document"
1475variant = "beta"
1476precedence = 10
1477"#,
1478 ))
1479 .expect_err("distinct variants cannot share precedence");
1480
1481 assert!(matches!(
1482 err,
1483 RulepackError::AmbiguousFamilyPrecedence {
1484 family,
1485 first_variant,
1486 second_variant,
1487 precedence: 10,
1488 } if family == "tenant-document"
1489 && first_variant == "alpha"
1490 && second_variant == "beta"
1491 ));
1492 }
1493
1494 #[test]
1495 fn rulepack_rejects_unsupported_token_format() {
1496 let err = Rulepack::parse(&unsupported_field_rulepack(
1497 "[recognizers.token]\nformat = \"Customer_{n}\"\n",
1498 ))
1499 .expect_err("token format is reserved for v0.4.1");
1500
1501 assert_unsupported_field(err, "token.format");
1502 }
1503
1504 #[test]
1505 fn rulepack_rejects_unsupported_context_hotwords() {
1506 let err = Rulepack::parse(&unsupported_field_rulepack(
1507 "[recognizers.context]\nhotwords = [\"foo\"]\n",
1508 ))
1509 .expect_err("context hotwords are reserved for v0.4.1");
1510
1511 assert_unsupported_field(err, "context.hotwords");
1512 }
1513
1514 #[test]
1515 fn rulepack_rejects_unsupported_context_boost() {
1516 let err = Rulepack::parse(&unsupported_field_rulepack(
1517 "[recognizers.context]\nboost = 0.10\n",
1518 ))
1519 .expect_err("context boost is reserved for v0.4.1");
1520
1521 assert_unsupported_field(err, "context.boost");
1522 }
1523
1524 #[test]
1525 fn rulepack_rejects_unsupported_context_window() {
1526 let err = Rulepack::parse(&unsupported_field_rulepack(
1527 "[recognizers.context]\nwindow = 12\n",
1528 ))
1529 .expect_err("context window is reserved for v0.4.1");
1530
1531 assert_unsupported_field(err, "context.window");
1532 }
1533
1534 #[test]
1535 fn rulepack_accepts_default_token_fields() {
1536 let rulepack = Rulepack::parse(CORE).expect("reserved token/context fields are unset");
1537 let recognizer = &rulepack.recognizers[0];
1538
1539 assert_eq!(recognizer.token.family, None);
1540 assert_eq!(recognizer.token.format, None);
1541 assert!(recognizer.context.as_ref().unwrap().hotwords.is_empty());
1542 assert_eq!(recognizer.context.as_ref().unwrap().boost, None);
1543 assert_eq!(recognizer.context.as_ref().unwrap().window, None);
1544 }
1545
1546 #[test]
1547 fn pattern_template_with_pattern_both_present_fails_closed() {
1548 let err = Rulepack::parse(&unsupported_field_rulepack(
1549 "pattern_template = \"{locale_email_headers}: (.+)\"\n",
1550 ))
1551 .expect_err("pattern and pattern_template are mutually exclusive");
1552
1553 assert!(matches!(
1554 err,
1555 RulepackError::RegexPatternChoice { id } if id == "bad.email"
1556 ));
1557 }
1558
1559 #[test]
1560 fn regex_pattern_or_template_is_required() {
1561 let raw = r#"
1562schema_version = "0.1.0"
1563rulepack_id = "bad"
1564rulepack_version = "0.4.0"
1565default_locales = ["global"]
1566
1567[[recognizers]]
1568id = "bad.email"
1569class = "Email"
1570enabled = true
1571
1572[recognizers.match]
1573kind = "regex"
1574"#;
1575 let err = Rulepack::parse(raw).expect_err("regex pattern is required");
1576
1577 assert!(matches!(
1578 err,
1579 RulepackError::RegexPatternChoice { id } if id == "bad.email"
1580 ));
1581 }
1582
1583 #[test]
1584 fn rulepack_load_accepts_fixture_email_regex() {
1585 let raw = r#"
1586schema_version = "0.1.0"
1587rulepack_id = "custom-email"
1588rulepack_version = "0.7.0"
1589default_locales = ["global"]
1590
1591[[recognizers]]
1592id = "custom.email"
1593class = "Email"
1594enabled = true
1595
1596[recognizers.match]
1597kind = "regex"
1598pattern = '''alice@example\.invalid'''
1599"#;
1600
1601 let rulepack = Rulepack::parse(raw).expect("standard email regex should load");
1602
1603 assert_eq!(rulepack.recognizers.len(), 1);
1604 assert_eq!(rulepack.recognizers[0].id, "custom.email");
1605 }
1606
1607 #[test]
1608 fn anchored_match_accepts_valid_schema() {
1609 let rulepack = Rulepack::parse(&anchored_match_rulepack("")).expect("anchored_match");
1610 assert!(matches!(
1611 rulepack.recognizers[0].matcher,
1612 RawMatch::AnchoredMatch { .. }
1613 ));
1614 }
1615
1616 #[test]
1617 fn anchored_match_rejects_unknown_boundary() {
1618 let err = Rulepack::parse(&anchored_match_rulepack("boundary = \"paragraph\"\n"))
1619 .expect_err("unknown boundary fails closed");
1620
1621 assert_unsupported_anchored_match(err, "boundary", "paragraph");
1622 }
1623
1624 #[test]
1625 fn anchored_match_rejects_unknown_name_shape() {
1626 let err = Rulepack::parse(&anchored_match_rulepack("name_shape = \"organization\"\n"))
1627 .expect_err("unknown name_shape fails closed");
1628
1629 assert_unsupported_anchored_match(err, "name_shape", "organization");
1630 }
1631
1632 #[test]
1633 fn anchored_match_rejects_unknown_cue_position() {
1634 let err = Rulepack::parse(&anchored_match_rulepack("cue_position = \"around\"\n"))
1635 .expect_err("unknown cue_position fails closed");
1636
1637 assert_unsupported_anchored_match(err, "cue_position", "around");
1638 }
1639
1640 #[test]
1641 fn anchored_match_rejects_missing_cues_bucket() {
1642 let err = Rulepack::parse(&anchored_match_rulepack("cues_bucket = \"\"\n"))
1643 .expect_err("missing cues_bucket fails closed");
1644
1645 assert_unsupported_anchored_match(err, "cues_bucket", "");
1646 }
1647
1648 #[test]
1649 fn anchored_match_rejects_ellipsis_in_cue_values() {
1650 let err = Rulepack::parse(
1651 r#"
1652schema_version = "0.1.0"
1653rulepack_id = "anchored"
1654rulepack_version = "0.6.0"
1655default_locales = ["global"]
1656
1657[locale.forward_markers]
1658names = ["Forwarded ... message"]
1659
1660[[recognizers]]
1661id = "name.forward_marker"
1662class = "Name"
1663enabled = true
1664
1665[recognizers.match]
1666kind = "anchored_match"
1667cues_bucket = "forward_markers"
1668boundary = "punctuation"
1669right_window_chars = 64
1670name_shape = "person_name"
1671cue_position = "before"
1672"#,
1673 )
1674 .expect_err("ellipsis cue fails closed");
1675
1676 assert_unsupported_anchored_match(
1677 err,
1678 "locale.forward_markers.names",
1679 "Forwarded ... message",
1680 );
1681 }
1682
1683 #[test]
1684 fn anchored_match_rejects_invalid_window_bounds() {
1685 for (value, expected) in [("0", "0"), ("513", "513")] {
1686 let err = Rulepack::parse(&anchored_match_rulepack(&format!(
1687 "right_window_chars = {value}\n"
1688 )))
1689 .expect_err("invalid right_window_chars fails closed");
1690
1691 assert_unsupported_anchored_match(err, "right_window_chars", expected);
1692 }
1693 }
1694
1695 #[test]
1696 fn rulepack_load_fails_when_two_name_recognizers_omit_cooperates_with() {
1697 let err = Rulepack::parse(
1698 r#"
1699schema_version = "0.1.0"
1700rulepack_id = "bad-composition"
1701rulepack_version = "0.4.1"
1702default_locales = ["global"]
1703
1704[[recognizers]]
1705id = "email.header.name"
1706class = "Name"
1707enabled = true
1708
1709[recognizers.match]
1710kind = "regex"
1711pattern = "From: ([A-Z][a-z]+)"
1712
1713[[recognizers]]
1714id = "salutation.name"
1715class = "Name"
1716enabled = true
1717
1718[recognizers.match]
1719kind = "regex"
1720pattern = "Dear ([A-Z][a-z]+)"
1721"#,
1722 )
1723 .expect_err("same-class recognizers must explicitly cooperate");
1724
1725 assert!(matches!(
1726 err,
1727 RulepackError::SameClassWithoutCooperation {
1728 class: PiiClass::Name,
1729 recognizer_a,
1730 recognizer_b,
1731 } if recognizer_a == "email.header.name" && recognizer_b == "salutation.name"
1732 ));
1733 }
1734
1735 #[test]
1736 fn rulepack_load_accepts_same_class_pair_with_cooperates_with() {
1737 let rulepack = Rulepack::parse(
1738 r#"
1739schema_version = "0.1.0"
1740rulepack_id = "cooperating-composition"
1741rulepack_version = "0.4.1"
1742default_locales = ["global"]
1743
1744[[recognizers]]
1745id = "email.header.name"
1746class = "Name"
1747cooperates_with = ["salutation.name"]
1748enabled = true
1749
1750[recognizers.match]
1751kind = "regex"
1752pattern = "From: ([A-Z][a-z]+)"
1753
1754[[recognizers]]
1755id = "salutation.name"
1756class = "Name"
1757enabled = true
1758
1759[recognizers.match]
1760kind = "regex"
1761pattern = "Dear ([A-Z][a-z]+)"
1762"#,
1763 )
1764 .expect("cooperates_with unblocks same-class recognizers");
1765
1766 assert_eq!(rulepack.recognizers.len(), 2);
1767 assert_eq!(
1768 rulepack.recognizers[0].cooperates_with,
1769 vec!["salutation.name"]
1770 );
1771 }
1772
1773 #[test]
1774 fn rejects_unknown_fields_with_parent_table_context() {
1775 let err = Rulepack::parse(
1776 r#"
1777schema_version = "0.1.0"
1778rulepack_id = "bad"
1779rulepack_version = "0.4.0"
1780default_locales = ["global"]
1781bogus = true
1782"#,
1783 )
1784 .expect_err("unknown field must fail");
1785
1786 assert!(matches!(err, RulepackError::Toml(_)));
1787 assert!(err.to_string().contains("bogus"));
1788 }
1789
1790 #[test]
1791 fn rejects_unsupported_schema_version() {
1792 let err = Rulepack::parse(
1793 r#"
1794schema_version = "0.2.0"
1795rulepack_id = "bad"
1796rulepack_version = "0.4.0"
1797"#,
1798 )
1799 .expect_err("unsupported schema");
1800
1801 assert!(matches!(err, RulepackError::SchemaVersion { .. }));
1802 }
1803
1804 #[test]
1805 fn class_spelling_accepts_pascal_case_and_custom_names() {
1806 assert_eq!(parse_class("Email").unwrap(), PiiClass::Email);
1807 assert_eq!(
1808 parse_class("custom:Class_Alpha").unwrap(),
1809 PiiClass::Custom("class_alpha".to_string())
1810 );
1811 }
1812
1813 fn unsupported_field_rulepack(extra: &str) -> String {
1814 format!(
1815 r#"
1816schema_version = "0.1.0"
1817rulepack_id = "bad"
1818rulepack_version = "0.4.0"
1819default_locales = ["global"]
1820
1821[[recognizers]]
1822id = "bad.email"
1823class = "Email"
1824enabled = true
1825
1826[recognizers.match]
1827kind = "regex"
1828pattern = "BAD_EMAIL_FIXTURE"
1829
1830{extra}
1831"#
1832 )
1833 }
1834
1835 fn collision_rulepack(first_collision: &str, second_collision: &str) -> String {
1836 format!(
1837 r#"
1838schema_version = "0.1.0"
1839rulepack_id = "collision-test"
1840rulepack_version = "0.7.0"
1841default_locales = ["global"]
1842
1843[[recognizers]]
1844id = "tenant.alpha"
1845class = "custom:tenant_alpha"
1846enabled = true
1847
1848[recognizers.match]
1849kind = "regex"
1850pattern = "ALPHA-[0-9]+"
1851
1852{first_collision}
1853
1854[[recognizers]]
1855id = "tenant.beta"
1856class = "custom:tenant_beta"
1857enabled = true
1858
1859[recognizers.match]
1860kind = "regex"
1861pattern = "BETA-[0-9]+"
1862
1863{second_collision}
1864"#
1865 )
1866 }
1867
1868 fn anchored_match_rulepack(override_line: &str) -> String {
1869 let cues_bucket = if override_line.starts_with("cues_bucket") {
1870 override_line.to_string()
1871 } else {
1872 "cues_bucket = \"forward_markers\"\n".to_string()
1873 };
1874 let boundary = if override_line.starts_with("boundary") {
1875 override_line.to_string()
1876 } else {
1877 "boundary = \"punctuation\"\n".to_string()
1878 };
1879 let right_window_chars = if override_line.starts_with("right_window_chars") {
1880 override_line.to_string()
1881 } else {
1882 "right_window_chars = 64\n".to_string()
1883 };
1884 let name_shape = if override_line.starts_with("name_shape") {
1885 override_line.to_string()
1886 } else {
1887 "name_shape = \"person_name\"\n".to_string()
1888 };
1889 let cue_position = if override_line.starts_with("cue_position") {
1890 override_line.to_string()
1891 } else {
1892 "cue_position = \"before\"\n".to_string()
1893 };
1894 format!(
1895 r#"
1896schema_version = "0.1.0"
1897rulepack_id = "anchored"
1898rulepack_version = "0.6.0"
1899default_locales = ["global"]
1900
1901[[recognizers]]
1902id = "name.forward_marker"
1903class = "Name"
1904enabled = true
1905
1906[recognizers.match]
1907kind = "anchored_match"
1908{cues_bucket}{boundary}{right_window_chars}{name_shape}{cue_position}
1909"#
1910 )
1911 }
1912
1913 fn assert_unsupported_field(err: RulepackError, field: &str) {
1914 assert!(matches!(
1915 err,
1916 RulepackError::UnsupportedField {
1917 field: ref actual,
1918 planned_version: "v0.4.1",
1919 } if actual == field
1920 ));
1921 }
1922
1923 fn assert_unsupported_anchored_match(err: RulepackError, field: &str, value: &str) {
1924 assert!(matches!(
1925 err,
1926 RulepackError::UnsupportedAnchoredMatch {
1927 field: ref actual_field,
1928 value: ref actual_value,
1929 } if actual_field == field && actual_value == value
1930 ));
1931 }
1932}