Skip to main content

gaze/
policy.rs

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
16/// `major.minor` prefix of the policy schema versions this build accepts.
17///
18/// A policy.toml `schema_version` (or the [`DEFAULT_POLICY_SCHEMA_VERSION`]
19/// soft default applied when the field is omitted) must start with this string.
20/// Anything else fails closed at load with
21/// [`PolicyError::PolicySchemaUnsupported`], so operators upgrading the binary
22/// learn about a contract break instead of silently mis-loading the policy.
23///
24/// Mirrors the rulepack-side `SUPPORTED_SCHEMA_MAJOR_MINOR` in
25/// [`crate::rulepack`].
26pub const SUPPORTED_POLICY_SCHEMA_MAJOR_MINOR: &str = "0.1";
27
28/// Schema version stamped on policy.toml documents that omit the field.
29///
30/// Existing 0.6.x / 0.7.x policies were written before `schema_version` was
31/// introduced; soft-defaulting them to `"0.1.0"` keeps the loader backward
32/// compatible. New policies should declare `schema_version = "0.1.0"`
33/// explicitly so future migrations can be detected.
34pub const DEFAULT_POLICY_SCHEMA_VERSION: &str = "0.1.0";
35
36/// Loaded redaction policy from a TOML configuration file.
37///
38/// Defines which rulepacks activate, which recognizers are enabled, and the locale chain.
39/// Load with [`Policy::load`] for library use or [`Policy::load_for_cli`] for CLI hosts.
40/// Both signatures take `&std::path::Path`.
41///
42/// Production deployments **must** use a policy -- the no-policy builder path is for
43/// development smoke-testing only and has an unauditable detection posture.
44///
45/// See `docs/reference/policy.md` in the repository for the full TOML schema reference.
46#[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    /// Declared policy schema version (e.g. `"0.1.0"`).
57    ///
58    /// Populated from policy.toml's top-level `schema_version` field. Loader
59    /// requires the `major.minor` prefix to match
60    /// [`SUPPORTED_POLICY_SCHEMA_MAJOR_MINOR`]; documents that omit the field
61    /// soft-default to [`DEFAULT_POLICY_SCHEMA_VERSION`] for backward
62    /// compatibility with policies written before v0.7.2.
63    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        // Body has zero rules — would normally trip NoRules — but the schema
1133        // gate must fire first so adopters see the version mismatch, not a
1134        // downstream validation surprise.
1135        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}