1use serde::{Deserialize, Serialize};
2use std::ops::Range;
3
4pub use crate::session_types::{
5 RedactionSession, RestorationEntry, RestorePermit, RestoreResult, SessionEntrySummary,
6 SessionSummary,
7};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
10#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
11#[serde(rename_all = "snake_case")]
12pub enum FindingKind {
13 Secret,
14 Domain,
15 Url,
16 Email,
17 Ip,
18 Cidr,
19 Phone,
20 Person,
21 Organization,
22 CustomString,
23 CustomFile,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(default)]
29pub struct RedactionRules {
30 pub secret: bool,
31 pub domain: bool,
32 pub url: bool,
33 pub email: bool,
34 pub ip: bool,
35 pub cidr: bool,
36 pub phone: bool,
37 pub person: bool,
38 pub organization: bool,
39}
40
41impl Default for RedactionRules {
42 fn default() -> Self {
43 Self {
44 secret: false,
45 domain: false,
46 url: false,
47 email: true,
48 ip: true,
49 cidr: true,
50 phone: false,
51 person: false,
52 organization: false,
53 }
54 }
55}
56
57impl RedactionRules {
58 pub fn with_kind(mut self, kind: FindingKind, enabled: bool) -> Self {
59 self.set_kind(kind, enabled);
60 self
61 }
62
63 pub fn set_kind(&mut self, kind: FindingKind, enabled: bool) {
64 match kind {
65 FindingKind::Secret => self.secret = enabled,
66 FindingKind::Domain => self.domain = enabled,
67 FindingKind::Url => self.url = enabled,
68 FindingKind::Email => self.email = enabled,
69 FindingKind::Ip => self.ip = enabled,
70 FindingKind::Cidr => self.cidr = enabled,
71 FindingKind::Phone => self.phone = enabled,
72 FindingKind::Person => self.person = enabled,
73 FindingKind::Organization => self.organization = enabled,
74 FindingKind::CustomString | FindingKind::CustomFile => {}
75 }
76 }
77
78 pub fn is_enabled(self, kind: FindingKind) -> bool {
79 match kind {
80 FindingKind::Secret => self.secret,
81 FindingKind::Domain => self.domain,
82 FindingKind::Url => self.url,
83 FindingKind::Email => self.email,
84 FindingKind::Ip => self.ip,
85 FindingKind::Cidr => self.cidr,
86 FindingKind::Phone => self.phone,
87 FindingKind::Person => self.person,
88 FindingKind::Organization => self.organization,
89 FindingKind::CustomString | FindingKind::CustomFile => true,
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
95#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
96#[serde(rename_all = "snake_case")]
97pub enum CustomStringMatch {
98 #[default]
99 Exact,
100 Contains,
101 Regex,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
105#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
106#[serde(rename_all = "snake_case")]
107pub enum CustomStringScope {
108 #[default]
109 Text,
110 Line,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
115pub struct CustomStringRule {
116 pub pattern: String,
117 #[serde(default)]
118 pub match_type: CustomStringMatch,
119 #[serde(default)]
120 pub scope: CustomStringScope,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
125pub struct CustomFileRule {
126 pub path: String,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
130#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
131pub struct RedactionPolicy {
132 #[serde(flatten)]
133 pub rules: RedactionRules,
134 #[serde(default)]
135 pub custom_strings: Vec<CustomStringRule>,
136 #[serde(default)]
137 pub custom_files: Vec<CustomFileRule>,
138}
139
140impl RedactionPolicy {
141 pub fn with_kind(mut self, kind: FindingKind, enabled: bool) -> Self {
142 self.rules.set_kind(kind, enabled);
143 self
144 }
145
146 pub fn with_custom_string(mut self, rule: CustomStringRule) -> Self {
147 self.custom_strings.push(rule);
148 self
149 }
150
151 pub fn with_custom_file(mut self, rule: CustomFileRule) -> Self {
152 self.custom_files.push(rule);
153 self
154 }
155
156 pub fn with_custom_strings<I: IntoIterator<Item = CustomStringRule>>(
157 mut self,
158 rules: I,
159 ) -> Self {
160 self.custom_strings.extend(rules);
161 self
162 }
163
164 pub fn with_custom_files<I: IntoIterator<Item = CustomFileRule>>(mut self, rules: I) -> Self {
165 self.custom_files.extend(rules);
166 self
167 }
168
169 pub fn validate(&self) -> Result<(), String> {
170 for (index, rule) in self.custom_strings.iter().enumerate() {
171 if rule.pattern.is_empty() {
172 return Err(format!(
173 "custom_strings[{index}]: pattern must not be empty"
174 ));
175 }
176 if matches!(rule.match_type, CustomStringMatch::Regex)
177 && regex::Regex::new(&rule.pattern).is_err()
178 {
179 return Err(format!(
180 "custom_strings[{index}]: invalid regex pattern: {}",
181 rule.pattern
182 ));
183 }
184 }
185 for (index, rule) in self.custom_files.iter().enumerate() {
186 if rule.path.is_empty() {
187 return Err(format!("custom_files[{index}]: path must not be empty"));
188 }
189 }
190 Ok(())
191 }
192}
193
194impl From<RedactionRules> for RedactionPolicy {
195 fn from(rules: RedactionRules) -> Self {
196 Self {
197 rules,
198 custom_strings: Vec::new(),
199 custom_files: Vec::new(),
200 }
201 }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205struct FindingKindMeta {
206 label: &'static str,
207 token_label: &'static str,
208 priority: u8,
209 containment_priority: u8,
210}
211
212impl FindingKind {
213 const fn meta(self) -> FindingKindMeta {
214 match self {
215 Self::Secret => FindingKindMeta {
216 label: "secret",
217 token_label: "SECRET",
218 priority: 100,
219 containment_priority: 75,
220 },
221 Self::Domain => FindingKindMeta {
222 label: "domain",
223 token_label: "DOMAIN",
224 priority: 70,
225 containment_priority: 80,
226 },
227 Self::Url => FindingKindMeta {
228 label: "url",
229 token_label: "URL",
230 priority: 90,
231 containment_priority: 100,
232 },
233 Self::Email => FindingKindMeta {
234 label: "email",
235 token_label: "EMAIL",
236 priority: 85,
237 containment_priority: 95,
238 },
239 Self::Ip => FindingKindMeta {
240 label: "ip",
241 token_label: "IP",
242 priority: 75,
243 containment_priority: 85,
244 },
245 Self::Cidr => FindingKindMeta {
246 label: "cidr",
247 token_label: "CIDR",
248 priority: 80,
249 containment_priority: 90,
250 },
251 Self::Phone => FindingKindMeta {
252 label: "phone",
253 token_label: "PHONE",
254 priority: 60,
255 containment_priority: 70,
256 },
257 Self::Person => FindingKindMeta {
258 label: "person",
259 token_label: "PERSON",
260 priority: 50,
261 containment_priority: 50,
262 },
263 Self::Organization => FindingKindMeta {
264 label: "organization",
265 token_label: "ORG",
266 priority: 45,
267 containment_priority: 45,
268 },
269 Self::CustomString => FindingKindMeta {
270 label: "custom_string",
271 token_label: "CSTR",
272 priority: 95,
273 containment_priority: 40,
274 },
275 Self::CustomFile => FindingKindMeta {
276 label: "custom_file",
277 token_label: "FILE",
278 priority: 99,
279 containment_priority: 99,
280 },
281 }
282 }
283
284 pub fn label(self) -> &'static str {
285 self.meta().label
286 }
287
288 pub fn token_label(self) -> &'static str {
289 self.meta().token_label
290 }
291
292 pub(crate) fn from_token_label(label: &str) -> Option<Self> {
293 match label {
294 "SECRET" => Some(Self::Secret),
295 "DOMAIN" => Some(Self::Domain),
296 "URL" => Some(Self::Url),
297 "EMAIL" => Some(Self::Email),
298 "IP" => Some(Self::Ip),
299 "CIDR" => Some(Self::Cidr),
300 "PHONE" => Some(Self::Phone),
301 "PERSON" => Some(Self::Person),
302 "ORG" => Some(Self::Organization),
303 "CSTR" => Some(Self::CustomString),
304 "FILE" => Some(Self::CustomFile),
305 _ => None,
306 }
307 }
308
309 pub fn priority(self) -> u8 {
310 self.meta().priority
311 }
312
313 pub fn containment_priority(self) -> u8 {
314 self.meta().containment_priority
315 }
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum FindingSource {
321 Rule,
322 Llm,
323}
324
325impl FindingSource {
326 pub fn bonus(self) -> u8 {
327 match self {
328 Self::Rule => 10,
329 Self::Llm => 0,
330 }
331 }
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct Finding {
336 pub kind: FindingKind,
337 pub source: FindingSource,
338 pub match_text: String,
339 pub normalized_key: String,
340 pub confidence: u8,
341 pub start: usize,
342 pub end: usize,
343}
344
345impl Finding {
346 pub fn range(&self) -> Range<usize> {
347 self.start..self.end
348 }
349
350 pub fn score(&self) -> u16 {
351 u16::from(self.kind.priority())
352 + u16::from(self.source.bonus())
353 + u16::from(self.confidence)
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "snake_case")]
359pub enum ReplacementStrategy {
360 StructuredToken,
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364pub struct AppliedReplacement {
365 pub kind: FindingKind,
366 #[serde(skip_serializing)]
367 pub original: String,
368 pub replacement: String,
369 pub strategy: ReplacementStrategy,
370 pub display_value: Option<String>,
371}
372
373#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
374pub struct RedactionStats {
375 pub total_findings: usize,
376 pub applied_replacements: usize,
377 pub dropped_findings: usize,
378 pub llm_configured: bool,
379 pub llm_request_failed: bool,
380 pub llm_candidates_accepted: usize,
381 pub llm_candidates_rejected: usize,
382 pub llm_error: Option<String>,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386pub struct RedactionResult {
387 pub redacted_text: String,
388 pub findings: Vec<Finding>,
389 pub applied_replacements: Vec<AppliedReplacement>,
390 pub stats: RedactionStats,
391}
392
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct RedactionArtifact {
395 pub result: RedactionResult,
396 pub session: RedactionSession,
397}