1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use serde::Deserialize;
7use thiserror::Error;
8
9use crate::{
10 Action, CollisionMembership, LocaleTag, PiiClass, RulepackDict, SafetyTier,
11 RESERVED_BUNDLED_FAMILIES,
12};
13
14pub const DEFAULT_NER_THRESHOLD: f32 = 0.3;
15
16pub const SUPPORTED_POLICY_SCHEMA_MAJOR_MINOR: &str = "0.1";
27
28pub const DEFAULT_POLICY_SCHEMA_VERSION: &str = "0.1.0";
35
36#[derive(Debug, Clone, PartialEq)]
47#[non_exhaustive]
48pub struct Policy {
49 pub session: SessionPolicy,
50 pub detectors: Vec<DetectorSpec>,
51 pub dictionaries: Vec<RulepackDict>,
52 pub rules: Vec<RuleSpec>,
53 pub ner: Option<NerPolicy>,
54 pub rulepacks: RulepackPolicy,
55 pub locale: Option<Vec<LocaleTag>>,
56 pub schema_version: String,
64}
65
66impl Default for Policy {
67 fn default() -> Self {
68 Self {
69 session: SessionPolicy::default(),
70 detectors: Vec::new(),
71 dictionaries: Vec::new(),
72 rules: Vec::new(),
73 ner: None,
74 rulepacks: RulepackPolicy::default(),
75 locale: None,
76 schema_version: DEFAULT_POLICY_SCHEMA_VERSION.to_string(),
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82#[non_exhaustive]
83pub struct SessionPolicy {
84 pub scope: SessionScope,
85 pub ttl_secs: Option<u64>,
86}
87
88impl Default for SessionPolicy {
89 fn default() -> Self {
90 Self {
91 scope: SessionScope::Ephemeral,
92 ttl_secs: None,
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum SessionScope {
100 Ephemeral,
101 Conversation,
102 Persistent,
103}
104
105impl SessionScope {
106 pub fn parse(value: &str) -> Result<Self, PolicyError> {
107 match value {
108 "ephemeral" => Ok(SessionScope::Ephemeral),
109 "conversation" => Ok(SessionScope::Conversation),
110 "persistent" => Ok(SessionScope::Persistent),
111 other => Err(PolicyError::SessionScopeUnknown {
112 value: other.to_string(),
113 }),
114 }
115 }
116}
117
118impl FromStr for SessionScope {
119 type Err = PolicyError;
120
121 fn from_str(value: &str) -> Result<Self, Self::Err> {
122 Self::parse(value)
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127#[non_exhaustive]
128pub struct DetectorSpec {
129 pub kind: DetectorKind,
130 pub name: String,
131 pub pattern: Option<String>,
132 pub class: PiiClass,
133 pub dictionary_name: Option<String>,
134 pub case_sensitive: bool,
135 pub token_family: String,
136 pub collision: Option<CollisionMembership>,
137 pub safety_tier: SafetyTier,
138}
139
140impl Default for DetectorSpec {
141 fn default() -> Self {
142 Self {
143 kind: DetectorKind::Regex,
144 name: String::new(),
145 pattern: None,
146 class: PiiClass::Email,
147 dictionary_name: None,
148 case_sensitive: false,
149 token_family: "counter".to_string(),
150 collision: None,
151 safety_tier: SafetyTier::OptIn,
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
157#[non_exhaustive]
158pub enum DetectorKind {
159 Regex,
160 Dictionary,
161 Unknown(String),
162}
163
164#[derive(Debug, Clone, PartialEq)]
165#[non_exhaustive]
166pub struct NerPolicy {
167 pub model_dir: Option<PathBuf>,
168 pub locale: Option<String>,
169 pub threshold: f32,
170}
171
172impl Default for NerPolicy {
173 fn default() -> Self {
174 Self {
175 model_dir: None,
176 locale: None,
177 threshold: DEFAULT_NER_THRESHOLD,
178 }
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Default)]
183#[non_exhaustive]
184pub struct RulepackPolicy {
185 pub bundled: Vec<String>,
186 pub paths: Vec<PathBuf>,
187 pub auto_activate_locale_gated: bool,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191#[non_exhaustive]
192pub enum RuleSpec {
193 Class { class: PiiClass, action: Action },
194 Column { column: String, action: Action },
195 Default { action: Action },
196}
197
198#[derive(Debug, Error)]
199#[non_exhaustive]
200pub enum PolicyError {
201 #[error("failed to parse policy.toml: {0}")]
202 TomlParse(#[source] toml::de::Error),
203 #[error("failed to read policy file: {0}")]
204 Io(#[source] std::io::Error),
205 #[error("unknown pii class: {0}")]
206 UnknownClass(String),
207 #[error("invalid regex for detector '{name}': {source}")]
208 BadRegex {
209 name: String,
210 #[source]
211 source: regex::Error,
212 },
213 #[error(
214 "regex detector '{name}' shadows Gaze token shape sample '{shadowed_shape}' with pattern '{pattern}'"
215 )]
216 TokenShapeShadow {
217 name: String,
218 pattern: String,
219 shadowed_shape: String,
220 },
221 #[error("invalid dictionary detector '{name}': {reason}")]
222 BadDictionary { name: String, reason: String },
223 #[error("session.ttl_secs is required when session.scope = \"persistent\"")]
224 MissingTtl,
225 #[error("invalid session.ttl_secs: {0}")]
226 BadTtl(String),
227 #[error("policy must define at least one rule")]
228 NoRules,
229 #[error("policy must define at least one detector")]
230 NoDetectors,
231 #[error(
232 "legacy [[detector]] is unsupported in v0.4; migrate to [[policy.custom_recognizers]]: {0}"
233 )]
234 LegacyDetectorUnsupported(&'static str),
235 #[error("ner load error: {0}")]
236 NerLoad(String),
237 #[error("ner.threshold must be between 0.0 and 1.0 inclusive, got {value}")]
238 NerThresholdOutOfRange { value: f32 },
239 #[error("session.scope must be one of ephemeral, conversation, persistent, got {value}")]
240 SessionScopeUnknown { value: String },
241 #[error("ner.locale must be a BCP47 locale tag, got {value}")]
242 NerLocaleUnsupported { value: String },
243 #[error("unknown bundled rulepack: {value}")]
244 BundledRulepackUnknown { value: String },
245 #[error("unknown locale bucket: {name}")]
246 UnknownLocaleBucket { name: String },
247 #[error("reserved collision family '{family}' cannot be used by policy custom recognizers")]
248 ReservedCollisionFamily { family: String },
249 #[error("invalid collision metadata for custom recognizer '{name}': {reason}")]
250 InvalidCollisionMetadata { name: String, reason: String },
251 #[error("{0}")]
252 UnsupportedRuleKind(String),
253 #[error("unsupported policy schema_version {found}; supported {supported}")]
254 PolicySchemaUnsupported {
255 found: String,
256 supported: &'static str,
257 },
258}
259
260impl Policy {
261 pub fn load(path: &Path) -> Result<Policy, PolicyError> {
262 let raw = fs::read_to_string(path).map_err(PolicyError::Io)?;
263 let raw: RawPolicy = toml::from_str(&raw).map_err(PolicyError::TomlParse)?;
264 raw.try_into()
265 }
266
267 pub fn load_for_cli(path: &Path) -> Result<Policy, PolicyError> {
268 let policy = Self::load(path)?;
269 if policy
270 .rules
271 .iter()
272 .any(|rule| matches!(rule, RuleSpec::Column { .. }))
273 {
274 return Err(PolicyError::UnsupportedRuleKind(
275 "column rules not supported in CLI mode".to_string(),
276 ));
277 }
278 Ok(policy)
279 }
280}
281
282#[derive(Debug, Deserialize)]
283#[serde(deny_unknown_fields)]
284struct RawPolicy {
285 #[serde(default = "default_raw_schema_version")]
286 schema_version: String,
287 session: RawSessionPolicy,
288 #[serde(rename = "detector", default)]
289 detectors: Vec<RawDetectorSpec>,
290 #[serde(rename = "rule", default)]
291 rules: Vec<RawRuleSpec>,
292 #[serde(default)]
293 ner: Option<RawNerPolicy>,
294 #[serde(default)]
295 locale: Option<RawLocalePolicy>,
296 #[serde(default)]
297 policy: Option<RawPolicyTables>,
298}
299
300fn default_raw_schema_version() -> String {
301 DEFAULT_POLICY_SCHEMA_VERSION.to_string()
302}
303
304#[derive(Debug, Deserialize)]
305#[serde(deny_unknown_fields)]
306struct RawSessionPolicy {
307 scope: String,
308 ttl_secs: Option<u64>,
309}
310
311#[derive(Debug, Deserialize)]
312#[serde(deny_unknown_fields)]
313struct RawDetectorSpec {
314 kind: String,
315 name: String,
316 pattern: Option<String>,
317 class: String,
318 dictionary: Option<String>,
319 #[serde(default)]
320 terms: Vec<String>,
321 terms_file: Option<String>,
322 terms_from_context: Option<String>,
323 #[serde(default)]
324 case_sensitive: bool,
325 token_family: Option<String>,
326 #[serde(default)]
327 collision: Option<crate::rulepack::RawCollisionSpec>,
328 #[serde(default)]
329 safety_tier: Option<String>,
330}
331
332#[derive(Debug, Deserialize)]
333#[serde(deny_unknown_fields)]
334struct RawNerPolicy {
335 model_dir: Option<String>,
336 locale: Option<String>,
337 #[serde(default)]
338 threshold: Option<f32>,
339}
340
341#[derive(Debug, Deserialize)]
342#[serde(deny_unknown_fields)]
343struct RawLocalePolicy {
344 #[serde(default)]
345 active: Vec<String>,
346}
347
348#[derive(Debug, Default, Deserialize)]
349#[serde(deny_unknown_fields)]
350struct RawPolicyTables {
351 #[serde(default)]
352 rulepacks: Option<RawRulepackPolicy>,
353 #[serde(default)]
354 custom_recognizers: Vec<RawDetectorSpec>,
355}
356
357#[derive(Debug, Deserialize)]
358#[serde(deny_unknown_fields)]
359struct RawRulepackPolicy {
360 #[serde(default)]
361 bundled: Vec<String>,
362 #[serde(default)]
363 paths: Vec<String>,
364}
365
366#[derive(Debug, Deserialize)]
367#[serde(deny_unknown_fields)]
368struct RawRuleSpec {
369 kind: String,
370 class: Option<String>,
371 column: Option<String>,
372 action: String,
373}
374
375impl TryFrom<RawPolicy> for Policy {
376 type Error = PolicyError;
377
378 fn try_from(raw: RawPolicy) -> Result<Self, Self::Error> {
379 if !raw
380 .schema_version
381 .starts_with(SUPPORTED_POLICY_SCHEMA_MAJOR_MINOR)
382 {
383 return Err(PolicyError::PolicySchemaUnsupported {
384 found: raw.schema_version,
385 supported: SUPPORTED_POLICY_SCHEMA_MAJOR_MINOR,
386 });
387 }
388 let schema_version = raw.schema_version;
389 let session = parse_session(raw.session)?;
390
391 if !raw.detectors.is_empty() {
392 return Err(PolicyError::LegacyDetectorUnsupported(
393 "https://github.com/CertaMesh/gaze/blob/main/docs/reference/policy.md#migrating-detector",
394 ));
395 }
396
397 let policy_tables = raw.policy.unwrap_or_default();
398 let RawPolicyTables {
399 rulepacks: raw_rulepacks,
400 custom_recognizers,
401 } = policy_tables;
402
403 let ner = raw.ner.map(parse_ner).transpose()?;
404 let mut detectors = Vec::with_capacity(custom_recognizers.len());
405 let mut dictionaries = Vec::new();
406 for detector in custom_recognizers {
407 let (detector, dictionary) = parse_detector(detector)?;
408 if let Some(dictionary) = dictionary {
409 dictionaries.push(dictionary);
410 }
411 detectors.push(detector);
412 }
413 let rulepacks = raw_rulepacks
414 .map(parse_rulepack_policy)
415 .transpose()?
416 .unwrap_or_else(|| RulepackPolicy {
417 bundled: vec!["core".to_string()],
418 paths: Vec::new(),
419 auto_activate_locale_gated: false,
420 });
421
422 if detectors.is_empty() && rulepacks.bundled.is_empty() && rulepacks.paths.is_empty() {
423 return Err(PolicyError::NoDetectors);
424 }
425
426 let mut rules = Vec::with_capacity(raw.rules.len());
427 for rule in raw.rules {
428 rules.push(parse_rule(rule)?);
429 }
430 if rules.is_empty() {
431 return Err(PolicyError::NoRules);
432 }
433
434 let locale = raw.locale.map(parse_locale_policy).transpose()?.flatten();
435
436 Ok(Self {
437 session,
438 detectors,
439 dictionaries,
440 rules,
441 ner,
442 rulepacks,
443 locale,
444 schema_version,
445 })
446 }
447}
448
449fn parse_session(raw: RawSessionPolicy) -> Result<SessionPolicy, PolicyError> {
450 let scope = SessionScope::parse(&raw.scope)?;
451
452 match scope {
453 SessionScope::Persistent => match raw.ttl_secs {
454 Some(0) => Err(PolicyError::BadTtl(
455 "session.ttl_secs must be greater than zero".to_string(),
456 )),
457 Some(ttl_secs) => Ok(SessionPolicy {
458 scope,
459 ttl_secs: Some(ttl_secs),
460 }),
461 None => Err(PolicyError::MissingTtl),
462 },
463 _ => {
464 if raw.ttl_secs == Some(0) {
465 return Err(PolicyError::BadTtl(
466 "session.ttl_secs must be greater than zero".to_string(),
467 ));
468 }
469 Ok(SessionPolicy {
470 scope,
471 ttl_secs: raw.ttl_secs,
472 })
473 }
474 }
475}
476
477fn parse_detector(
478 raw: RawDetectorSpec,
479) -> Result<(DetectorSpec, Option<RulepackDict>), PolicyError> {
480 let class = parse_class(&raw.class)?;
481 let collision = parse_detector_collision(&raw)?;
482 let safety_tier = parse_custom_safety_tier(raw.safety_tier.as_deref())?;
483 match raw.kind.as_str() {
484 "regex" => parse_regex_detector(raw, class, collision, safety_tier),
485 "dictionary" => parse_dictionary_detector(raw, class, collision, safety_tier),
486 other => Ok((
487 DetectorSpec {
488 kind: DetectorKind::Unknown(other.to_string()),
489 name: raw.name,
490 pattern: raw.pattern,
491 class,
492 dictionary_name: None,
493 case_sensitive: raw.case_sensitive,
494 token_family: raw.token_family.unwrap_or_else(|| "counter".to_string()),
495 collision,
496 safety_tier,
497 },
498 None,
499 )),
500 }
501}
502
503fn parse_detector_collision(
504 raw: &RawDetectorSpec,
505) -> Result<Option<CollisionMembership>, PolicyError> {
506 let Some(collision) = raw.collision.clone() else {
507 return Ok(None);
508 };
509 if RESERVED_BUNDLED_FAMILIES
510 .iter()
511 .any(|family| *family == collision.family)
512 {
513 return Err(PolicyError::ReservedCollisionFamily {
514 family: collision.family,
515 });
516 }
517 crate::rulepack::parse_collision_membership(&raw.name, collision)
518 .map(Some)
519 .map_err(|err| PolicyError::InvalidCollisionMetadata {
520 name: raw.name.clone(),
521 reason: err.to_string(),
522 })
523}
524
525fn parse_regex_detector(
526 raw: RawDetectorSpec,
527 class: PiiClass,
528 collision: Option<CollisionMembership>,
529 safety_tier: SafetyTier,
530) -> Result<(DetectorSpec, Option<RulepackDict>), PolicyError> {
531 let pattern = raw.pattern.ok_or_else(|| PolicyError::BadDictionary {
532 name: raw.name.clone(),
533 reason: "regex recognizers require pattern".to_string(),
534 })?;
535 let compiled = regex::Regex::new(&pattern).map_err(|source| PolicyError::BadRegex {
536 name: raw.name.clone(),
537 source,
538 })?;
539 crate::token_shape::reject_if_shadows_token_shape(&compiled, &raw.name).map_err(|shadow| {
540 PolicyError::TokenShapeShadow {
541 name: shadow.recognizer_id,
542 pattern: shadow.offending_pattern,
543 shadowed_shape: shadow.shadowed_shape,
544 }
545 })?;
546
547 Ok((
548 DetectorSpec {
549 kind: DetectorKind::Regex,
550 name: raw.name,
551 pattern: Some(pattern),
552 class,
553 dictionary_name: None,
554 case_sensitive: false,
555 token_family: raw.token_family.unwrap_or_else(|| "counter".to_string()),
556 collision,
557 safety_tier,
558 },
559 None,
560 ))
561}
562
563fn parse_dictionary_detector(
564 raw: RawDetectorSpec,
565 class: PiiClass,
566 collision: Option<CollisionMembership>,
567 safety_tier: SafetyTier,
568) -> Result<(DetectorSpec, Option<RulepackDict>), PolicyError> {
569 if raw.pattern.is_some() {
570 return Err(PolicyError::BadDictionary {
571 name: raw.name,
572 reason: "dictionary recognizers must not set pattern".to_string(),
573 });
574 }
575
576 let dictionary_name = raw
577 .terms_from_context
578 .clone()
579 .or(raw.dictionary.clone())
580 .unwrap_or_else(|| raw.name.clone());
581 let mut terms = raw.terms;
582 if let Some(path) = raw.terms_file {
583 let path = expand_home(path)?;
584 let file = fs::read_to_string(&path).map_err(PolicyError::Io)?;
585 terms.extend(
586 file.lines()
587 .map(str::trim)
588 .filter(|line| !line.is_empty() && !line.starts_with('#'))
589 .map(str::to_string),
590 );
591 }
592
593 let dictionary = if raw.terms_from_context.is_some() {
594 if !terms.is_empty() {
595 return Err(PolicyError::BadDictionary {
596 name: raw.name.clone(),
597 reason: "terms_from_context cannot be combined with terms or terms_file"
598 .to_string(),
599 });
600 }
601 None
602 } else {
603 if terms.is_empty() {
604 return Err(PolicyError::BadDictionary {
605 name: raw.name.clone(),
606 reason: "dictionary recognizers require terms, terms_file, or terms_from_context"
607 .to_string(),
608 });
609 }
610 if !raw.case_sensitive && terms.iter().any(|term| !term.is_ascii()) {
611 return Err(PolicyError::BadDictionary {
612 name: raw.name.clone(),
613 reason:
614 "unicode dictionary insensitive matching unsupported in v0.4.0, use case_sensitive = true"
615 .to_string(),
616 });
617 }
618 Some(RulepackDict::new(
619 dictionary_name.clone(),
620 terms,
621 raw.case_sensitive,
622 ))
623 };
624
625 Ok((
626 DetectorSpec {
627 kind: DetectorKind::Dictionary,
628 name: raw.name,
629 pattern: None,
630 class,
631 dictionary_name: Some(dictionary_name),
632 case_sensitive: raw.case_sensitive,
633 token_family: raw.token_family.unwrap_or_else(|| "counter".to_string()),
634 collision,
635 safety_tier,
636 },
637 dictionary,
638 ))
639}
640
641fn parse_custom_safety_tier(raw: Option<&str>) -> Result<SafetyTier, PolicyError> {
642 raw.map(SafetyTier::parse)
643 .transpose()
644 .map_err(|err| PolicyError::BadTtl(err.to_string()))
645 .map(|tier| tier.unwrap_or(SafetyTier::OptIn))
646}
647
648fn parse_rule(raw: RawRuleSpec) -> Result<RuleSpec, PolicyError> {
649 let action = parse_action(&raw.action)?;
650 match raw.kind.as_str() {
651 "class" => {
652 let class = raw
653 .class
654 .ok_or_else(|| PolicyError::UnknownClass("missing rule.class".to_string()))?;
655 Ok(RuleSpec::Class {
656 class: parse_class(&class)?,
657 action,
658 })
659 }
660 "column" => Ok(RuleSpec::Column {
661 column: raw
662 .column
663 .ok_or_else(|| PolicyError::BadTtl("missing rule.column".to_string()))?,
664 action,
665 }),
666 "default" => Ok(RuleSpec::Default { action }),
667 other => Err(PolicyError::BadTtl(format!("unknown rule.kind '{other}'"))),
668 }
669}
670
671fn parse_ner(raw: RawNerPolicy) -> Result<NerPolicy, PolicyError> {
672 let threshold = raw.threshold.unwrap_or(DEFAULT_NER_THRESHOLD);
673 if !(0.0..=1.0).contains(&threshold) {
674 return Err(PolicyError::NerThresholdOutOfRange { value: threshold });
675 }
676 if let Some(locale) = &raw.locale {
677 validate_ner_locale(locale)?;
678 }
679 Ok(NerPolicy {
680 model_dir: raw.model_dir.map(expand_home).transpose()?,
681 locale: raw.locale,
682 threshold,
683 })
684}
685
686pub fn validate_ner_locale(locale: &str) -> Result<(), PolicyError> {
687 LocaleTag::parse(locale)
688 .map(|_| ())
689 .map_err(|_| PolicyError::NerLocaleUnsupported {
690 value: locale.to_string(),
691 })
692}
693
694fn parse_locale_policy(raw: RawLocalePolicy) -> Result<Option<Vec<LocaleTag>>, PolicyError> {
695 if raw.active.is_empty() {
696 return Ok(None);
697 }
698 raw.active
699 .into_iter()
700 .map(|locale| {
701 LocaleTag::parse(&locale)
702 .map_err(|_| PolicyError::BadTtl(format!("unsupported locale tag '{locale}'")))
703 })
704 .collect::<Result<Vec<_>, _>>()
705 .map(Some)
706}
707
708fn parse_rulepack_policy(raw: RawRulepackPolicy) -> Result<RulepackPolicy, PolicyError> {
709 let (bundled, auto_activate_locale_gated) = normalize_bundled_rulepacks(raw.bundled);
710 Ok(RulepackPolicy {
711 bundled,
712 paths: raw
713 .paths
714 .into_iter()
715 .map(expand_home)
716 .collect::<Result<_, _>>()?,
717 auto_activate_locale_gated,
718 })
719}
720
721fn normalize_bundled_rulepacks(raw: Vec<String>) -> (Vec<String>, bool) {
722 let mut bundled = Vec::with_capacity(raw.len());
723 let mut auto_activate_locale_gated = false;
724 for bundle in raw {
725 if bundle == "core-extended" {
726 auto_activate_locale_gated = true;
727 tracing::warn!(
728 "`core-extended` bundled rulepack is deprecated since v0.8.0; use `core` with an explicit locale"
729 );
730 if !bundled.iter().any(|existing| existing == "core") {
731 bundled.push("core".to_string());
732 }
733 } else if !bundled.iter().any(|existing| existing == &bundle) {
734 bundled.push(bundle);
735 }
736 }
737 (bundled, auto_activate_locale_gated)
738}
739
740fn expand_home(path: String) -> Result<PathBuf, PolicyError> {
741 if let Some(rest) = path.strip_prefix("~/") {
742 let home = env::var("HOME")
743 .map_err(|_| PolicyError::BadTtl("HOME is not set for ~/ expansion".to_string()))?;
744 Ok(PathBuf::from(home).join(rest))
745 } else {
746 Ok(PathBuf::from(path))
747 }
748}
749
750fn parse_class(input: &str) -> Result<PiiClass, PolicyError> {
751 let lower = input.trim().to_ascii_lowercase();
752 match lower.as_str() {
753 "email" => Ok(PiiClass::Email),
754 "name" => Ok(PiiClass::Name),
755 "location" => Ok(PiiClass::Location),
756 "organization" => Ok(PiiClass::Organization),
757 custom if custom.starts_with("custom:") => {
758 let name = input
759 .trim()
760 .split_once(':')
761 .map(|(_, name)| name)
762 .unwrap_or_default();
763 if name.trim().is_empty() {
764 return Err(PolicyError::UnknownClass(input.to_string()));
765 }
766 if name.starts_with("family:") {
767 Ok(PiiClass::Custom(name.to_string()))
768 } else {
769 Ok(PiiClass::custom(name))
770 }
771 }
772 _ => Err(PolicyError::UnknownClass(input.to_string())),
773 }
774}
775
776fn parse_action(input: &str) -> Result<Action, PolicyError> {
777 match input {
778 "tokenize" => Ok(Action::Tokenize),
779 "redact" => Ok(Action::Redact),
780 "format_preserve" => Ok(Action::FormatPreserve),
781 "generalize" => Ok(Action::Generalize),
782 "preserve" => Ok(Action::Preserve),
783 other => Err(PolicyError::BadTtl(format!(
784 "unknown rule.action '{other}'"
785 ))),
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use std::fs;
792
793 use tempfile::tempdir;
794
795 use super::*;
796
797 #[test]
798 fn loads_policy_and_expands_home() {
799 let dir = tempdir().unwrap();
800 let path = dir.path().join("policy.toml");
801 fs::write(
802 &path,
803 r#"
804[session]
805scope = "persistent"
806ttl_secs = 86400
807
808[[policy.custom_recognizers]]
809kind = "regex"
810name = "emails"
811pattern = 'alice@example\.invalid'
812class = "email"
813
814[ner]
815model_dir = "~/.cache/gaze/model"
816locale = "de"
817threshold = 0.4
818
819[[rule]]
820kind = "class"
821class = "email"
822action = "tokenize"
823
824[[rule]]
825kind = "default"
826action = "preserve"
827"#,
828 )
829 .unwrap();
830
831 let old_home = env::var_os("HOME");
832 env::set_var("HOME", "/tmp/gaze-home");
833 let policy = Policy::load(&path).unwrap();
834 match old_home {
835 Some(value) => env::set_var("HOME", value),
836 None => env::remove_var("HOME"),
837 }
838
839 assert_eq!(policy.session.scope, SessionScope::Persistent);
840 assert_eq!(policy.session.ttl_secs, Some(86400));
841 assert_eq!(policy.detectors.len(), 1);
842 assert_eq!(policy.rules.len(), 2);
843 let ner = policy.ner.unwrap();
844 assert_eq!(
845 ner.model_dir,
846 Some(PathBuf::from("/tmp/gaze-home/.cache/gaze/model"))
847 );
848 assert_eq!(ner.threshold, 0.4);
849 }
850
851 #[test]
852 fn rejects_ner_threshold_out_of_range() {
853 let raw = r#"
854[session]
855scope = "ephemeral"
856
857[ner]
858threshold = 1.1
859
860[[policy.custom_recognizers]]
861kind = "regex"
862name = "emails"
863pattern = ".+"
864class = "email"
865
866[[rule]]
867kind = "default"
868action = "preserve"
869"#;
870 let raw: RawPolicy = toml::from_str(raw).expect("raw policy");
871
872 assert!(matches!(
873 Policy::try_from(raw),
874 Err(PolicyError::NerThresholdOutOfRange { value }) if value == 1.1
875 ));
876 }
877
878 #[test]
879 fn accepts_bcp47_ner_locale_hints() {
880 for locale in ["de", "en-US", "pt-BR", "zh-Hant"] {
881 assert!(
882 validate_ner_locale(locale).is_ok(),
883 "NER locale hints should accept BCP47-shaped tag {locale}"
884 );
885 }
886
887 assert!(matches!(
888 validate_ner_locale("bad locale!"),
889 Err(PolicyError::NerLocaleUnsupported { value }) if value == "bad locale!"
890 ));
891 }
892
893 #[test]
894 fn rejects_unknown_session_scope_with_typed_error() {
895 let raw = r#"
896[session]
897scope = "forever"
898
899[[policy.custom_recognizers]]
900kind = "regex"
901name = "emails"
902pattern = ".+"
903class = "email"
904
905[[rule]]
906kind = "default"
907action = "preserve"
908"#;
909
910 let raw = toml::from_str::<RawPolicy>(raw).unwrap();
911 let err = Policy::try_from(raw).unwrap_err();
912
913 assert!(matches!(
914 err,
915 PolicyError::SessionScopeUnknown { value } if value == "forever"
916 ));
917 }
918
919 #[test]
920 fn custom_email_recognizer_loads_under_preservation() {
921 let raw = r#"
922[session]
923scope = "ephemeral"
924
925[[policy.custom_recognizers]]
926kind = "regex"
927name = "emails"
928pattern = 'alice@example\.invalid'
929class = "email"
930
931[[rule]]
932kind = "default"
933action = "preserve"
934"#;
935
936 let raw = toml::from_str::<RawPolicy>(raw).unwrap();
937 let policy = Policy::try_from(raw).unwrap();
938
939 assert_eq!(policy.detectors.len(), 1);
940 assert_eq!(policy.detectors[0].name, "emails");
941 }
942
943 #[test]
944 fn policy_with_custom_collision_family_parses() {
945 let raw = r#"
946[session]
947scope = "ephemeral"
948
949[[policy.custom_recognizers]]
950kind = "regex"
951name = "tenant.order"
952pattern = 'ORD-[0-9]+'
953class = "custom:tenant_ref"
954
955[policy.custom_recognizers.collision]
956family = "tenant-orders"
957variant = "order-id"
958precedence = 50
959
960[[rule]]
961kind = "default"
962action = "preserve"
963"#;
964
965 let raw = toml::from_str::<RawPolicy>(raw).unwrap();
966 let policy = Policy::try_from(raw).unwrap();
967
968 let collision = policy.detectors[0].collision.as_ref().expect("collision");
969 assert_eq!(collision.family, "tenant-orders");
970 assert_eq!(collision.variant, "order-id");
971 assert_eq!(collision.precedence, 50);
972 }
973
974 #[test]
975 fn policy_with_reserved_family_rejected() {
976 let raw = r#"
977[session]
978scope = "ephemeral"
979
980[[policy.custom_recognizers]]
981kind = "regex"
982name = "tenant.card"
983pattern = '[0-9]+'
984class = "custom:tenant_card"
985
986[policy.custom_recognizers.collision]
987family = "payment-card-or-iban"
988variant = "tenant-card"
989precedence = 50
990
991[[rule]]
992kind = "default"
993action = "preserve"
994"#;
995
996 let raw = toml::from_str::<RawPolicy>(raw).unwrap();
997 let err = Policy::try_from(raw).unwrap_err();
998
999 assert!(matches!(
1000 err,
1001 PolicyError::ReservedCollisionFamily { family }
1002 if family == "payment-card-or-iban"
1003 ));
1004 }
1005
1006 #[test]
1007 fn rejects_unknown_keys() {
1008 let dir = tempdir().unwrap();
1009 let path = dir.path().join("policy.toml");
1010 fs::write(
1011 &path,
1012 r#"
1013[session]
1014scope = "ephemeral"
1015bogus = true
1016
1017[[policy.custom_recognizers]]
1018kind = "regex"
1019name = "emails"
1020pattern = ".+"
1021class = "email"
1022
1023[[rule]]
1024kind = "default"
1025action = "preserve"
1026"#,
1027 )
1028 .unwrap();
1029
1030 assert!(matches!(
1031 Policy::load(&path),
1032 Err(PolicyError::TomlParse(_))
1033 ));
1034 }
1035
1036 #[test]
1037 fn loads_dictionary_custom_recognizer_terms() {
1038 let dir = tempdir().unwrap();
1039 let path = dir.path().join("policy.toml");
1040 fs::write(
1041 &path,
1042 r#"
1043[session]
1044scope = "ephemeral"
1045
1046[[policy.custom_recognizers]]
1047kind = "dictionary"
1048name = "songs"
1049class = "custom:song"
1050terms = ["Song A"]
1051case_sensitive = true
1052
1053[[rule]]
1054kind = "class"
1055class = "custom:song"
1056action = "tokenize"
1057
1058[[rule]]
1059kind = "default"
1060action = "preserve"
1061"#,
1062 )
1063 .unwrap();
1064
1065 let policy = Policy::load(&path).unwrap();
1066 assert_eq!(policy.detectors[0].kind, DetectorKind::Dictionary);
1067 assert_eq!(
1068 policy.detectors[0].dictionary_name.as_deref(),
1069 Some("songs")
1070 );
1071 assert_eq!(policy.dictionaries[0].terms, vec!["Song A"]);
1072 }
1073
1074 fn minimal_policy_body(schema_line: &str) -> String {
1075 format!(
1076 r#"{schema_line}
1077[session]
1078scope = "persistent"
1079ttl_secs = 86400
1080
1081[[policy.custom_recognizers]]
1082kind = "regex"
1083name = "emails"
1084pattern = 'a@b'
1085class = "email"
1086
1087[[rule]]
1088kind = "default"
1089action = "preserve"
1090"#
1091 )
1092 }
1093
1094 fn write_policy(dir: &tempfile::TempDir, body: &str) -> PathBuf {
1095 let path = dir.path().join("policy.toml");
1096 fs::write(&path, body).unwrap();
1097 path
1098 }
1099
1100 #[test]
1101 fn schema_version_omitted_soft_defaults_to_supported() {
1102 let dir = tempdir().unwrap();
1103 let path = write_policy(&dir, &minimal_policy_body(""));
1104 let policy = Policy::load(&path).expect("missing schema_version should soft-default");
1105 assert_eq!(policy.schema_version, DEFAULT_POLICY_SCHEMA_VERSION);
1106 }
1107
1108 #[test]
1109 fn schema_version_explicit_matching_major_minor_is_accepted() {
1110 let dir = tempdir().unwrap();
1111 let path = write_policy(&dir, &minimal_policy_body(r#"schema_version = "0.1.7""#));
1112 let policy = Policy::load(&path).expect("0.1.x must be accepted");
1113 assert_eq!(policy.schema_version, "0.1.7");
1114 }
1115
1116 #[test]
1117 fn schema_version_unsupported_major_fails_closed() {
1118 let dir = tempdir().unwrap();
1119 let path = write_policy(&dir, &minimal_policy_body(r#"schema_version = "0.2.0""#));
1120 let err = Policy::load(&path).expect_err("0.2.x must be rejected");
1121 match err {
1122 PolicyError::PolicySchemaUnsupported { found, supported } => {
1123 assert_eq!(found, "0.2.0");
1124 assert_eq!(supported, SUPPORTED_POLICY_SCHEMA_MAJOR_MINOR);
1125 }
1126 other => panic!("expected PolicySchemaUnsupported, got {other:?}"),
1127 }
1128 }
1129
1130 #[test]
1131 fn schema_version_unsupported_fails_before_other_validation() {
1132 let dir = tempdir().unwrap();
1136 let path = write_policy(
1137 &dir,
1138 r#"
1139schema_version = "9.9.9"
1140[session]
1141scope = "persistent"
1142ttl_secs = 86400
1143"#,
1144 );
1145 let err = Policy::load(&path).expect_err("must reject schema first");
1146 assert!(
1147 matches!(err, PolicyError::PolicySchemaUnsupported { .. }),
1148 "expected PolicySchemaUnsupported, got {err:?}"
1149 );
1150 }
1151}