1use std::path::{Path, PathBuf};
43
44use alint_core::{
45 Context, Error, Level, PathsSpec, PerFileRule, Result, Rule, RuleSpec, Scope, ScopeFilter,
46 Violation,
47};
48use regex::Regex;
49use serde::Deserialize;
50use serde_json::Value;
51use serde_json_path::JsonPath;
52
53fn is_literal_path(pattern: &str) -> bool {
58 !pattern.starts_with('!')
59 && !pattern
60 .chars()
61 .any(|c| matches!(c, '*' | '?' | '[' | ']' | '{' | '}'))
62}
63
64fn extract_literal_paths(spec: &PathsSpec) -> Option<Vec<PathBuf>> {
69 let patterns: Vec<&str> = match spec {
70 PathsSpec::Single(s) => vec![s.as_str()],
71 PathsSpec::Many(v) => v.iter().map(String::as_str).collect(),
72 PathsSpec::IncludeExclude { include, exclude } if exclude.is_empty() => {
73 include.iter().map(String::as_str).collect()
74 }
75 PathsSpec::IncludeExclude { .. } => return None,
76 };
77 if patterns.iter().all(|p| is_literal_path(p)) {
78 Some(patterns.iter().map(PathBuf::from).collect())
79 } else {
80 None
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Format {
87 Json,
88 Yaml,
89 Toml,
90}
91
92impl Format {
93 pub(crate) fn parse(self, text: &str) -> std::result::Result<Value, String> {
94 match self {
95 Self::Json => serde_json::from_str(text).map_err(|e| e.to_string()),
96 Self::Yaml => serde_yaml_ng::from_str(text).map_err(|e| e.to_string()),
97 Self::Toml => toml::from_str(text).map_err(|e| e.to_string()),
98 }
99 }
100
101 pub(crate) fn label(self) -> &'static str {
102 match self {
103 Self::Json => "JSON",
104 Self::Yaml => "YAML",
105 Self::Toml => "TOML",
106 }
107 }
108
109 pub(crate) fn detect_from_path(path: &std::path::Path) -> Option<Self> {
114 match path.extension()?.to_str()? {
115 "json" => Some(Self::Json),
116 "yaml" | "yml" => Some(Self::Yaml),
117 "toml" => Some(Self::Toml),
118 _ => None,
119 }
120 }
121}
122
123#[derive(Debug)]
125pub enum Op {
126 Equals(Value),
130 Matches(Regex),
134}
135
136#[derive(Debug, Deserialize)]
142struct EqualsOptions {
143 path: String,
144 equals: Value,
145 #[serde(default)]
146 if_present: bool,
147}
148
149#[derive(Debug, Deserialize)]
151struct MatchesOptions {
152 path: String,
153 matches: String,
154 #[serde(default)]
155 if_present: bool,
156}
157
158#[derive(Debug)]
163pub struct StructuredPathRule {
164 id: String,
165 level: Level,
166 policy_url: Option<String>,
167 message: Option<String>,
168 scope: Scope,
169 scope_filter: Option<ScopeFilter>,
170 literal_paths: Option<Vec<PathBuf>>,
182 format: Format,
183 path_expr: JsonPath,
184 path_src: String,
185 op: Op,
186 if_present: bool,
194}
195
196impl Rule for StructuredPathRule {
197 fn id(&self) -> &str {
198 &self.id
199 }
200 fn level(&self) -> Level {
201 self.level
202 }
203 fn policy_url(&self) -> Option<&str> {
204 self.policy_url.as_deref()
205 }
206
207 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
208 let mut violations = Vec::new();
209 if let Some(literals) = self.literal_paths.as_ref() {
210 for literal in literals {
221 if !ctx.index.contains_file(literal) {
222 continue;
223 }
224 if let Some(filter) = &self.scope_filter
225 && !filter.matches(literal, ctx.index)
226 {
227 continue;
228 }
229 let full = ctx.root.join(literal);
230 let Ok(bytes) = std::fs::read(&full) else {
231 continue;
232 };
233 violations.extend(self.evaluate_file(ctx, literal, &bytes)?);
234 }
235 } else {
236 for entry in ctx.index.files() {
237 if !self.scope.matches(&entry.path) {
238 continue;
239 }
240 if let Some(filter) = &self.scope_filter
241 && !filter.matches(&entry.path, ctx.index)
242 {
243 continue;
244 }
245 let full = ctx.root.join(&entry.path);
246 let Ok(bytes) = std::fs::read(&full) else {
247 continue;
250 };
251 violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
252 }
253 }
254 Ok(violations)
255 }
256
257 fn as_per_file(&self) -> Option<&dyn PerFileRule> {
258 Some(self)
259 }
260
261 fn scope_filter(&self) -> Option<&ScopeFilter> {
262 self.scope_filter.as_ref()
263 }
264}
265
266impl PerFileRule for StructuredPathRule {
267 fn path_scope(&self) -> &Scope {
268 &self.scope
269 }
270
271 fn evaluate_file(
272 &self,
273 _ctx: &Context<'_>,
274 path: &Path,
275 bytes: &[u8],
276 ) -> Result<Vec<Violation>> {
277 let Ok(text) = std::str::from_utf8(bytes) else {
278 return Ok(Vec::new());
279 };
280 let root_value = match self.format.parse(text) {
281 Ok(v) => v,
282 Err(err) => {
283 return Ok(vec![
284 Violation::new(format!(
285 "not a valid {} document: {err}",
286 self.format.label()
287 ))
288 .with_path(std::sync::Arc::<Path>::from(path)),
289 ]);
290 }
291 };
292 let matches = self.path_expr.query(&root_value);
293 if matches.is_empty() {
294 if self.if_present {
295 return Ok(Vec::new());
296 }
297 let msg = self
298 .message
299 .clone()
300 .unwrap_or_else(|| format!("JSONPath `{}` produced no match", self.path_src));
301 return Ok(vec![
302 Violation::new(msg).with_path(std::sync::Arc::<Path>::from(path)),
303 ]);
304 }
305 let mut violations = Vec::new();
306 for m in matches.iter() {
307 if let Some(v) = check_match(m, &self.op) {
308 let base = self.message.clone().unwrap_or(v);
309 violations.push(Violation::new(base).with_path(std::sync::Arc::<Path>::from(path)));
310 }
311 }
312 Ok(violations)
313 }
314}
315
316fn check_match(m: &Value, op: &Op) -> Option<String> {
318 match op {
319 Op::Equals(expected) => {
320 if m == expected {
321 None
322 } else {
323 Some(format!(
324 "value at path does not equal expected: expected {}, got {}",
325 short_render(expected),
326 short_render(m),
327 ))
328 }
329 }
330 Op::Matches(re) => {
331 let Some(s) = m.as_str() else {
332 return Some(format!(
333 "value at path is not a string (got {}), can't apply regex",
334 kind_name(m)
335 ));
336 };
337 if re.is_match(s) {
338 None
339 } else {
340 Some(format!(
341 "value at path {} does not match regex {}",
342 short_render(m),
343 re.as_str(),
344 ))
345 }
346 }
347 }
348}
349
350fn short_render(v: &Value) -> String {
353 let raw = v.to_string();
354 if raw.len() <= 80 {
355 raw
356 } else {
357 format!("{}…", &raw[..80])
358 }
359}
360
361fn kind_name(v: &Value) -> &'static str {
362 match v {
363 Value::Null => "null",
364 Value::Bool(_) => "bool",
365 Value::Number(_) => "number",
366 Value::String(_) => "string",
367 Value::Array(_) => "array",
368 Value::Object(_) => "object",
369 }
370}
371
372pub fn json_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
381 build_equals(spec, Format::Json, "json_path_equals")
382}
383
384pub fn json_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
385 build_matches(spec, Format::Json, "json_path_matches")
386}
387
388pub fn yaml_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
389 build_equals(spec, Format::Yaml, "yaml_path_equals")
390}
391
392pub fn yaml_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
393 build_matches(spec, Format::Yaml, "yaml_path_matches")
394}
395
396pub fn toml_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
397 build_equals(spec, Format::Toml, "toml_path_equals")
398}
399
400pub fn toml_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
401 build_matches(spec, Format::Toml, "toml_path_matches")
402}
403
404fn build_equals(spec: &RuleSpec, format: Format, kind_label: &str) -> Result<Box<dyn Rule>> {
405 let paths = spec.paths.as_ref().ok_or_else(|| {
406 Error::rule_config(&spec.id, format!("{kind_label} requires a `paths` field"))
407 })?;
408 let opts: EqualsOptions = spec
409 .deserialize_options()
410 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
411 let path_expr = JsonPath::parse(&opts.path).map_err(|e| {
412 Error::rule_config(&spec.id, format!("invalid JSONPath {:?}: {e}", opts.path))
413 })?;
414 Ok(Box::new(StructuredPathRule {
415 id: spec.id.clone(),
416 level: spec.level,
417 policy_url: spec.policy_url.clone(),
418 message: spec.message.clone(),
419 scope: Scope::from_paths_spec(paths)?,
420 scope_filter: spec.parse_scope_filter()?,
421 literal_paths: extract_literal_paths(paths),
422 format,
423 path_expr,
424 path_src: opts.path,
425 op: Op::Equals(opts.equals),
426 if_present: opts.if_present,
427 }))
428}
429
430fn build_matches(spec: &RuleSpec, format: Format, kind_label: &str) -> Result<Box<dyn Rule>> {
431 let paths = spec.paths.as_ref().ok_or_else(|| {
432 Error::rule_config(&spec.id, format!("{kind_label} requires a `paths` field"))
433 })?;
434 let opts: MatchesOptions = spec
435 .deserialize_options()
436 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
437 let path_expr = JsonPath::parse(&opts.path).map_err(|e| {
438 Error::rule_config(&spec.id, format!("invalid JSONPath {:?}: {e}", opts.path))
439 })?;
440 let re = Regex::new(&opts.matches).map_err(|e| {
441 Error::rule_config(&spec.id, format!("invalid regex {:?}: {e}", opts.matches))
442 })?;
443 Ok(Box::new(StructuredPathRule {
444 id: spec.id.clone(),
445 level: spec.level,
446 policy_url: spec.policy_url.clone(),
447 message: spec.message.clone(),
448 scope: Scope::from_paths_spec(paths)?,
449 scope_filter: spec.parse_scope_filter()?,
450 literal_paths: extract_literal_paths(paths),
451 format,
452 path_expr,
453 path_src: opts.path,
454 op: Op::Matches(re),
455 if_present: opts.if_present,
456 }))
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
463
464 #[test]
467 fn build_rejects_missing_paths() {
468 let spec = spec_yaml(
469 "id: t\n\
470 kind: json_path_equals\n\
471 path: \"$.name\"\n\
472 equals: \"x\"\n\
473 level: error\n",
474 );
475 assert!(json_path_equals_build(&spec).is_err());
476 }
477
478 #[test]
479 fn build_rejects_invalid_jsonpath() {
480 let spec = spec_yaml(
481 "id: t\n\
482 kind: json_path_equals\n\
483 paths: \"package.json\"\n\
484 path: \"$..[invalid\"\n\
485 equals: \"x\"\n\
486 level: error\n",
487 );
488 assert!(json_path_equals_build(&spec).is_err());
489 }
490
491 #[test]
492 fn build_rejects_invalid_regex_in_matches() {
493 let spec = spec_yaml(
494 "id: t\n\
495 kind: json_path_matches\n\
496 paths: \"package.json\"\n\
497 path: \"$.version\"\n\
498 pattern: \"[unterminated\"\n\
499 level: error\n",
500 );
501 assert!(json_path_matches_build(&spec).is_err());
502 }
503
504 #[test]
507 fn json_path_equals_passes_when_value_matches() {
508 let spec = spec_yaml(
509 "id: t\n\
510 kind: json_path_equals\n\
511 paths: \"package.json\"\n\
512 path: \"$.name\"\n\
513 equals: \"demo\"\n\
514 level: error\n",
515 );
516 let rule = json_path_equals_build(&spec).unwrap();
517 let (tmp, idx) =
518 tempdir_with_files(&[("package.json", br#"{"name":"demo","version":"1.0.0"}"#)]);
519 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
520 assert!(v.is_empty(), "matching value should pass: {v:?}");
521 }
522
523 #[test]
524 fn json_path_equals_fires_on_mismatch() {
525 let spec = spec_yaml(
526 "id: t\n\
527 kind: json_path_equals\n\
528 paths: \"package.json\"\n\
529 path: \"$.name\"\n\
530 equals: \"demo\"\n\
531 level: error\n",
532 );
533 let rule = json_path_equals_build(&spec).unwrap();
534 let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"name":"other"}"#)]);
535 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
536 assert_eq!(v.len(), 1);
537 }
538
539 #[test]
540 fn json_path_equals_fires_on_missing_path() {
541 let spec = spec_yaml(
542 "id: t\n\
543 kind: json_path_equals\n\
544 paths: \"package.json\"\n\
545 path: \"$.name\"\n\
546 equals: \"demo\"\n\
547 level: error\n",
548 );
549 let rule = json_path_equals_build(&spec).unwrap();
550 let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.0"}"#)]);
551 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
552 assert_eq!(v.len(), 1, "missing path should fire");
553 }
554
555 #[test]
556 fn json_path_if_present_silent_on_missing() {
557 let spec = spec_yaml(
559 "id: t\n\
560 kind: json_path_equals\n\
561 paths: \"package.json\"\n\
562 path: \"$.name\"\n\
563 equals: \"demo\"\n\
564 if_present: true\n\
565 level: error\n",
566 );
567 let rule = json_path_equals_build(&spec).unwrap();
568 let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.0"}"#)]);
569 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
570 assert!(v.is_empty(), "if_present should silence: {v:?}");
571 }
572
573 #[test]
576 fn json_path_matches_passes_on_pattern_hit() {
577 let spec = spec_yaml(
578 "id: t\n\
579 kind: json_path_matches\n\
580 paths: \"package.json\"\n\
581 path: \"$.version\"\n\
582 matches: \"^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\"\n\
583 level: error\n",
584 );
585 let rule = json_path_matches_build(&spec).unwrap();
586 let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.2.3"}"#)]);
587 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
588 assert!(v.is_empty(), "matching version should pass: {v:?}");
589 }
590
591 #[test]
592 fn json_path_matches_fires_on_pattern_miss() {
593 let spec = spec_yaml(
594 "id: t\n\
595 kind: json_path_matches\n\
596 paths: \"package.json\"\n\
597 path: \"$.version\"\n\
598 matches: \"^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\"\n\
599 level: error\n",
600 );
601 let rule = json_path_matches_build(&spec).unwrap();
602 let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"v1.x"}"#)]);
603 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
604 assert_eq!(v.len(), 1);
605 }
606
607 #[test]
610 fn yaml_path_equals_passes_when_value_matches() {
611 let spec = spec_yaml(
612 "id: t\n\
613 kind: yaml_path_equals\n\
614 paths: \".github/workflows/*.yml\"\n\
615 path: \"$.name\"\n\
616 equals: \"CI\"\n\
617 level: error\n",
618 );
619 let rule = yaml_path_equals_build(&spec).unwrap();
620 let (tmp, idx) = tempdir_with_files(&[(
621 ".github/workflows/ci.yml",
622 b"name: CI\non: push\njobs: {}\n",
623 )]);
624 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
625 assert!(v.is_empty(), "matching name should pass: {v:?}");
626 }
627
628 #[test]
629 fn yaml_path_matches_uses_bracket_notation_for_dashed_keys() {
630 let spec = spec_yaml(
634 "id: t\n\
635 kind: yaml_path_matches\n\
636 paths: \"action.yml\"\n\
637 path: \"$.runs['using']\"\n\
638 matches: \"^node\\\\d+$\"\n\
639 level: error\n",
640 );
641 let rule = yaml_path_matches_build(&spec).unwrap();
642 let (tmp, idx) =
643 tempdir_with_files(&[("action.yml", b"runs:\n using: node20\n main: index.js\n")]);
644 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
645 assert!(v.is_empty(), "bracket notation should match: {v:?}");
646 }
647
648 #[test]
651 fn toml_path_equals_passes_when_value_matches() {
652 let spec = spec_yaml(
653 "id: t\n\
654 kind: toml_path_equals\n\
655 paths: \"Cargo.toml\"\n\
656 path: \"$.package.edition\"\n\
657 equals: \"2024\"\n\
658 level: error\n",
659 );
660 let rule = toml_path_equals_build(&spec).unwrap();
661 let (tmp, idx) = tempdir_with_files(&[(
662 "Cargo.toml",
663 b"[package]\nname = \"x\"\nedition = \"2024\"\n",
664 )]);
665 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
666 assert!(v.is_empty(), "matching edition should pass: {v:?}");
667 }
668
669 #[test]
670 fn toml_path_matches_fires_on_floating_version() {
671 let spec = spec_yaml(
673 "id: t\n\
674 kind: toml_path_matches\n\
675 paths: \"Cargo.toml\"\n\
676 path: \"$.dependencies.serde\"\n\
677 matches: \"^[~=]\"\n\
678 level: error\n",
679 );
680 let rule = toml_path_matches_build(&spec).unwrap();
681 let (tmp, idx) = tempdir_with_files(&[(
682 "Cargo.toml",
683 b"[package]\nname = \"x\"\n[dependencies]\nserde = \"1\"\n",
684 )]);
685 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
686 assert_eq!(v.len(), 1, "floating `serde = \"1\"` should fire");
687 }
688
689 #[test]
692 fn evaluate_fires_on_malformed_input() {
693 let spec = spec_yaml(
694 "id: t\n\
695 kind: json_path_equals\n\
696 paths: \"package.json\"\n\
697 path: \"$.name\"\n\
698 equals: \"x\"\n\
699 level: error\n",
700 );
701 let rule = json_path_equals_build(&spec).unwrap();
702 let (tmp, idx) = tempdir_with_files(&[("package.json", b"{not valid json")]);
703 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
704 assert_eq!(v.len(), 1, "malformed JSON should fire one violation");
705 }
706}