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