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