1use crate::{
2 ByteSpan, Comment, CommentKind, Diagnostic, Language, Layout, PreparedScanner, ScanOptions,
3 ScanReport, Severity, TransformOptions, TransformPlan, TransformResult,
4 scanner::{DispositionPatterns, disposition},
5};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
62#[serde(default, deny_unknown_fields)]
63pub struct DeclarativeProfile {
64 pub name: String,
66 #[serde(default)]
70 pub extensions: Vec<String>,
71 #[serde(default)]
73 pub line_comments: Vec<LineDelimiter>,
74 #[serde(default)]
76 pub block_comments: Vec<BlockDelimiter>,
77 #[serde(default)]
79 pub strings: Vec<StringDelimiter>,
80 #[serde(default)]
82 pub protected_patterns: Vec<ProtectedPattern>,
83}
84
85#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct LineDelimiter {
89 pub start: String,
91 #[serde(default)]
95 pub requires_boundary: bool,
96 #[serde(default)]
98 pub kind: CommentKind,
99}
100
101#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct BlockDelimiter {
105 pub start: String,
107 pub end: String,
109 #[serde(default)]
112 pub nested: bool,
113 #[serde(default)]
115 pub kind: CommentKind,
116}
117
118#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct StringDelimiter {
122 pub start: String,
124 pub end: String,
126 #[serde(default)]
128 pub escape: Option<String>,
129 #[serde(default)]
133 pub multiline: bool,
134}
135
136#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct ProtectedPattern {
145 pub contains: String,
148 pub reason: String,
150}
151
152#[derive(Clone, Debug, Error, Eq, PartialEq)]
157pub enum ProfileError {
158 #[error("profile name must not be empty")]
160 EmptyName,
161 #[error("profile must define at least one comment delimiter")]
163 NoCommentDelimiter,
164 #[error("delimiter `{0}` must not be empty")]
166 EmptyDelimiter(&'static str),
167 #[error("ambiguous delimiter prefix: `{0}` and `{1}`")]
169 AmbiguousDelimiter(String, String),
170 #[error("ambiguous string delimiter prefix: `{0}` and `{1}`")]
172 AmbiguousStringDelimiter(String, String),
173 #[error("ambiguous comment/string delimiter prefix: `{0}` and `{1}`")]
176 CommentStringCollision(String, String),
177 #[error("nested block delimiters require distinct non-overlapping start and end tokens")]
180 InvalidNesting,
181 #[error("delimiter contains a newline")]
183 NewlineDelimiter,
184 #[error("protected patterns need non-empty `contains` and `reason` values")]
186 EmptyProtectedPattern,
187 #[error("invalid policy regex: {0}")]
190 InvalidPolicyRegex(String),
191}
192
193pub fn validate_profile(profile: &DeclarativeProfile) -> Result<(), ProfileError> {
204 if profile.name.trim().is_empty() {
205 return Err(ProfileError::EmptyName);
206 }
207 if profile.line_comments.is_empty() && profile.block_comments.is_empty() {
208 return Err(ProfileError::NoCommentDelimiter);
209 }
210 let mut comments: Vec<&str> = Vec::new();
211 for delimiter in &profile.line_comments {
212 validate_token(&delimiter.start, "line start")?;
213 comments.push(&delimiter.start);
214 }
215 for delimiter in &profile.block_comments {
216 validate_token(&delimiter.start, "block start")?;
217 validate_token(&delimiter.end, "block end")?;
218 if delimiter.nested
219 && (delimiter.start == delimiter.end
220 || delimiter.start.contains(&delimiter.end)
221 || delimiter.end.contains(&delimiter.start))
222 {
223 return Err(ProfileError::InvalidNesting);
224 }
225 comments.push(&delimiter.start);
226 }
227 let mut strings: Vec<&str> = Vec::new();
228 for delimiter in &profile.strings {
229 validate_token(&delimiter.start, "string start")?;
230 validate_token(&delimiter.end, "string end")?;
231 if let Some(escape) = &delimiter.escape {
232 validate_token(escape, "string escape")?;
233 }
234 strings.push(&delimiter.start);
235 }
236 for (index, left) in comments.iter().enumerate() {
237 for right in comments.iter().skip(index + 1) {
238 if left.starts_with(*right) || right.starts_with(*left) {
239 return Err(ProfileError::AmbiguousDelimiter(
240 (*left).into(),
241 (*right).into(),
242 ));
243 }
244 }
245 if let Some(right) = strings
246 .iter()
247 .find(|right| left.starts_with(**right) || right.starts_with(*left))
248 {
249 return Err(ProfileError::CommentStringCollision(
250 (*left).into(),
251 (**right).into(),
252 ));
253 }
254 }
255 for (index, left) in strings.iter().enumerate() {
256 for right in strings.iter().skip(index + 1) {
257 if left.starts_with(*right) || right.starts_with(*left) {
258 return Err(ProfileError::AmbiguousStringDelimiter(
259 (*left).into(),
260 (*right).into(),
261 ));
262 }
263 }
264 }
265 if profile
266 .protected_patterns
267 .iter()
268 .any(|pattern| pattern.contains.is_empty() || pattern.reason.trim().is_empty())
269 {
270 return Err(ProfileError::EmptyProtectedPattern);
271 }
272 Ok(())
273}
274
275pub fn scan_profile(
289 source: &[u8],
290 profile: &DeclarativeProfile,
291 options: ScanOptions,
292) -> Result<ScanReport, ProfileError> {
293 let prepared = PreparedScanner::new(options)
294 .map_err(|error| ProfileError::InvalidPolicyRegex(error.to_string()))?;
295 prepared.scan_profile(source, profile)
296}
297
298impl PreparedScanner {
299 pub fn scan_profile(
301 &self,
302 source: &[u8],
303 profile: &DeclarativeProfile,
304 ) -> Result<ScanReport, ProfileError> {
305 scan_profile_with(source, profile, self.options(), &self.patterns)
306 }
307
308 pub fn transform_profile_plan(
311 &self,
312 source: &[u8],
313 profile: &DeclarativeProfile,
314 layout: Layout,
315 ) -> Result<TransformPlan, ProfileError> {
316 let report = self.scan_profile(source, profile)?;
317 Ok(crate::transform::plan_report(
318 source,
319 report,
320 layout,
321 self.options().force_invalid,
322 ))
323 }
324}
325
326fn scan_profile_with(
327 source: &[u8],
328 profile: &DeclarativeProfile,
329 options: &ScanOptions,
330 patterns: &DispositionPatterns,
331) -> Result<ScanReport, ProfileError> {
332 validate_profile(profile)?;
333 let mut comments = Vec::new();
334 let mut diagnostics = Vec::new();
335 let mut index = 0;
336 while index < source.len() {
337 if let Some(string) = profile
338 .strings
339 .iter()
340 .find(|string| starts(source, index, string.start.as_bytes()))
341 {
342 let start = index;
343 index += string.start.len();
344 let mut closed = false;
345 while index < source.len() {
346 if starts(source, index, string.end.as_bytes()) {
347 index += string.end.len();
348 closed = true;
349 break;
350 }
351 if let Some(escape) = &string.escape
352 && starts(source, index, escape.as_bytes())
353 {
354 index = (index + escape.len() + 1).min(source.len());
355 continue;
356 }
357 if !string.multiline && matches!(source[index], b'\r' | b'\n') {
358 break;
359 }
360 index += 1;
361 }
362 if !closed {
363 diagnostics.push(Diagnostic {
364 code: "unterminated-profile-string".into(),
365 message: format!("unterminated string in profile `{}`", profile.name),
366 severity: Severity::Error,
367 span: ByteSpan::new(start, index),
368 });
369 }
370 continue;
371 }
372 if let Some(delimiter) = profile.line_comments.iter().find(|delimiter| {
373 starts(source, index, delimiter.start.as_bytes())
374 && (!delimiter.requires_boundary
375 || index == 0
376 || source[index - 1].is_ascii_whitespace())
377 }) {
378 let mut end = index + delimiter.start.len();
379 while end < source.len() && !matches!(source[end], b'\r' | b'\n') {
380 end += 1;
381 }
382 comments.push(profile_comment(
383 source,
384 index,
385 end,
386 delimiter.kind,
387 profile,
388 options,
389 patterns,
390 ));
391 index = end;
392 continue;
393 }
394 if let Some(delimiter) = profile
395 .block_comments
396 .iter()
397 .find(|delimiter| starts(source, index, delimiter.start.as_bytes()))
398 {
399 let start = index;
400 index += delimiter.start.len();
401 let mut depth = 1usize;
402 while index < source.len() {
403 if delimiter.nested && starts(source, index, delimiter.start.as_bytes()) {
404 depth += 1;
405 index += delimiter.start.len();
406 } else if starts(source, index, delimiter.end.as_bytes()) {
407 depth -= 1;
408 index += delimiter.end.len();
409 if depth == 0 {
410 break;
411 }
412 } else {
413 index += 1;
414 }
415 }
416 comments.push(profile_comment(
417 source,
418 start,
419 index,
420 delimiter.kind,
421 profile,
422 options,
423 patterns,
424 ));
425 if depth != 0 {
426 diagnostics.push(Diagnostic {
427 code: "unterminated-profile-comment".into(),
428 message: format!("unterminated block comment in profile `{}`", profile.name),
429 severity: Severity::Error,
430 span: ByteSpan::new(start, index),
431 });
432 }
433 continue;
434 }
435 index += 1;
436 }
437 let valid = diagnostics.is_empty();
438 Ok(ScanReport {
439 language: Language::Unknown,
440 comments,
441 diagnostics,
442 valid,
443 })
444}
445
446pub fn transform_profile(
456 source: &[u8],
457 profile: &DeclarativeProfile,
458 options: TransformOptions,
459) -> Result<TransformResult, ProfileError> {
460 let prepared = PreparedScanner::new(options.scan)
461 .map_err(|error| ProfileError::InvalidPolicyRegex(error.to_string()))?;
462 Ok(prepared
463 .transform_profile_plan(source, profile, options.layout)?
464 .finish(source))
465}
466
467fn profile_comment(
468 source: &[u8],
469 start: usize,
470 end: usize,
471 mut kind: CommentKind,
472 profile: &DeclarativeProfile,
473 options: &ScanOptions,
474 patterns: &DispositionPatterns,
475) -> Comment {
476 let raw = String::from_utf8_lossy(&source[start..end]);
477 let protected = profile
478 .protected_patterns
479 .iter()
480 .find(|pattern| raw.contains(&pattern.contains));
481 if protected.is_some() {
482 kind = CommentKind::Directive;
483 }
484 let mut disposition = disposition(kind, options, &source[start..end], patterns);
485 if let (Some(pattern), crate::Disposition::Keep { reason }) = (protected, &mut disposition) {
486 *reason = pattern.reason.clone();
487 }
488 Comment {
489 span: ByteSpan::new(start, end),
490 kind,
491 disposition,
492 }
493}
494
495fn starts(source: &[u8], index: usize, token: &[u8]) -> bool {
496 source.get(index..index.saturating_add(token.len())) == Some(token)
497}
498
499fn validate_token(token: &str, name: &'static str) -> Result<(), ProfileError> {
500 if token.is_empty() {
501 return Err(ProfileError::EmptyDelimiter(name));
502 }
503 if token.contains(['\r', '\n']) {
504 return Err(ProfileError::NewlineDelimiter);
505 }
506 Ok(())
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 #[test]
513 fn rejects_prefix_ambiguity() {
514 let profile = DeclarativeProfile {
515 name: "x".into(),
516 line_comments: vec![
517 LineDelimiter {
518 start: "/".into(),
519 requires_boundary: false,
520 kind: CommentKind::Line,
521 },
522 LineDelimiter {
523 start: "//".into(),
524 requires_boundary: false,
525 kind: CommentKind::Line,
526 },
527 ],
528 ..Default::default()
529 };
530 assert!(matches!(
531 validate_profile(&profile),
532 Err(ProfileError::AmbiguousDelimiter(..))
533 ));
534 }
535
536 #[test]
537 fn scans_profile_without_looking_inside_strings() {
538 let profile = DeclarativeProfile {
539 name: "demo".into(),
540 line_comments: vec![LineDelimiter {
541 start: ";;".into(),
542 requires_boundary: false,
543 kind: CommentKind::Line,
544 }],
545 strings: vec![StringDelimiter {
546 start: "\"".into(),
547 end: "\"".into(),
548 escape: Some("\\".into()),
549 multiline: false,
550 }],
551 ..Default::default()
552 };
553 let report = scan_profile(b"\";; no\" ;; yes\n", &profile, ScanOptions::default()).unwrap();
554 assert_eq!(report.comments.len(), 1);
555 assert_eq!(
556 &b"\";; no\" ;; yes\n"[report.comments[0].span.start..report.comments[0].span.end],
557 b";; yes"
558 );
559 }
560
561 #[test]
562 fn rejects_empty_and_string_ambiguous_profiles() {
563 assert_eq!(
564 validate_profile(&DeclarativeProfile {
565 name: "empty".into(),
566 ..Default::default()
567 }),
568 Err(ProfileError::NoCommentDelimiter)
569 );
570 let profile = DeclarativeProfile {
571 name: "ambiguous".into(),
572 line_comments: vec![LineDelimiter {
573 start: "#".into(),
574 requires_boundary: false,
575 kind: CommentKind::Line,
576 }],
577 strings: vec![StringDelimiter {
578 start: "##".into(),
579 end: "##".into(),
580 escape: None,
581 multiline: false,
582 }],
583 ..Default::default()
584 };
585 assert!(matches!(
586 validate_profile(&profile),
587 Err(ProfileError::CommentStringCollision(..))
588 ));
589 }
590}