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)]
141struct EqualsOptions {
142 path: String,
143 equals: Value,
144 #[serde(default)]
145 if_present: bool,
146}
147
148#[derive(Debug, Deserialize)]
150struct MatchesOptions {
151 path: String,
152 matches: String,
153 #[serde(default)]
154 if_present: bool,
155}
156
157#[derive(Debug)]
162pub struct StructuredPathRule {
163 id: String,
164 level: Level,
165 policy_url: Option<String>,
166 message: Option<String>,
167 scope: Scope,
168 literal_paths: Option<Vec<PathBuf>>,
180 format: Format,
181 path_expr: JsonPath,
182 path_src: String,
183 op: Op,
184 if_present: bool,
192}
193
194impl Rule for StructuredPathRule {
195 fn id(&self) -> &str {
196 &self.id
197 }
198 fn level(&self) -> Level {
199 self.level
200 }
201 fn policy_url(&self) -> Option<&str> {
202 self.policy_url.as_deref()
203 }
204
205 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
206 let mut violations = Vec::new();
207 if let Some(literals) = self.literal_paths.as_ref() {
208 for literal in literals {
219 if !ctx.index.contains_file(literal) {
220 continue;
221 }
222 let full = ctx.root.join(literal);
223 let Ok(bytes) = std::fs::read(&full) else {
224 continue;
225 };
226 violations.extend(self.evaluate_file(ctx, literal, &bytes)?);
227 }
228 } else {
229 for entry in ctx.index.files() {
230 if !self.scope.matches(&entry.path) {
231 continue;
232 }
233 let full = ctx.root.join(&entry.path);
234 let Ok(bytes) = std::fs::read(&full) else {
235 continue;
238 };
239 violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
240 }
241 }
242 Ok(violations)
243 }
244
245 fn as_per_file(&self) -> Option<&dyn PerFileRule> {
246 Some(self)
247 }
248}
249
250impl PerFileRule for StructuredPathRule {
251 fn path_scope(&self) -> &Scope {
252 &self.scope
253 }
254
255 fn evaluate_file(
256 &self,
257 _ctx: &Context<'_>,
258 path: &Path,
259 bytes: &[u8],
260 ) -> Result<Vec<Violation>> {
261 let Ok(text) = std::str::from_utf8(bytes) else {
262 return Ok(Vec::new());
263 };
264 let root_value = match self.format.parse(text) {
265 Ok(v) => v,
266 Err(err) => {
267 return Ok(vec![
268 Violation::new(format!(
269 "not a valid {} document: {err}",
270 self.format.label()
271 ))
272 .with_path(std::sync::Arc::<Path>::from(path)),
273 ]);
274 }
275 };
276 let matches = self.path_expr.query(&root_value);
277 if matches.is_empty() {
278 if self.if_present {
279 return Ok(Vec::new());
280 }
281 let msg = self
282 .message
283 .clone()
284 .unwrap_or_else(|| format!("JSONPath `{}` produced no match", self.path_src));
285 return Ok(vec![
286 Violation::new(msg).with_path(std::sync::Arc::<Path>::from(path)),
287 ]);
288 }
289 let mut violations = Vec::new();
290 for m in matches.iter() {
291 if let Some(v) = check_match(m, &self.op) {
292 let base = self.message.clone().unwrap_or(v);
293 violations.push(Violation::new(base).with_path(std::sync::Arc::<Path>::from(path)));
294 }
295 }
296 Ok(violations)
297 }
298}
299
300fn check_match(m: &Value, op: &Op) -> Option<String> {
302 match op {
303 Op::Equals(expected) => {
304 if m == expected {
305 None
306 } else {
307 Some(format!(
308 "value at path does not equal expected: expected {}, got {}",
309 short_render(expected),
310 short_render(m),
311 ))
312 }
313 }
314 Op::Matches(re) => {
315 let Some(s) = m.as_str() else {
316 return Some(format!(
317 "value at path is not a string (got {}), can't apply regex",
318 kind_name(m)
319 ));
320 };
321 if re.is_match(s) {
322 None
323 } else {
324 Some(format!(
325 "value at path {} does not match regex {}",
326 short_render(m),
327 re.as_str(),
328 ))
329 }
330 }
331 }
332}
333
334fn short_render(v: &Value) -> String {
337 let raw = v.to_string();
338 if raw.len() <= 80 {
339 raw
340 } else {
341 format!("{}…", &raw[..80])
342 }
343}
344
345fn kind_name(v: &Value) -> &'static str {
346 match v {
347 Value::Null => "null",
348 Value::Bool(_) => "bool",
349 Value::Number(_) => "number",
350 Value::String(_) => "string",
351 Value::Array(_) => "array",
352 Value::Object(_) => "object",
353 }
354}
355
356pub fn json_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
365 build_equals(spec, Format::Json, "json_path_equals")
366}
367
368pub fn json_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
369 build_matches(spec, Format::Json, "json_path_matches")
370}
371
372pub fn yaml_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
373 build_equals(spec, Format::Yaml, "yaml_path_equals")
374}
375
376pub fn yaml_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
377 build_matches(spec, Format::Yaml, "yaml_path_matches")
378}
379
380pub fn toml_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
381 build_equals(spec, Format::Toml, "toml_path_equals")
382}
383
384pub fn toml_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
385 build_matches(spec, Format::Toml, "toml_path_matches")
386}
387
388fn build_equals(spec: &RuleSpec, format: Format, kind_label: &str) -> Result<Box<dyn Rule>> {
389 let paths = spec.paths.as_ref().ok_or_else(|| {
390 Error::rule_config(&spec.id, format!("{kind_label} requires a `paths` field"))
391 })?;
392 let opts: EqualsOptions = spec
393 .deserialize_options()
394 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
395 let path_expr = JsonPath::parse(&opts.path).map_err(|e| {
396 Error::rule_config(&spec.id, format!("invalid JSONPath {:?}: {e}", opts.path))
397 })?;
398 Ok(Box::new(StructuredPathRule {
399 id: spec.id.clone(),
400 level: spec.level,
401 policy_url: spec.policy_url.clone(),
402 message: spec.message.clone(),
403 scope: Scope::from_paths_spec(paths)?,
404 literal_paths: extract_literal_paths(paths),
405 format,
406 path_expr,
407 path_src: opts.path,
408 op: Op::Equals(opts.equals),
409 if_present: opts.if_present,
410 }))
411}
412
413fn build_matches(spec: &RuleSpec, format: Format, kind_label: &str) -> Result<Box<dyn Rule>> {
414 let paths = spec.paths.as_ref().ok_or_else(|| {
415 Error::rule_config(&spec.id, format!("{kind_label} requires a `paths` field"))
416 })?;
417 let opts: MatchesOptions = spec
418 .deserialize_options()
419 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
420 let path_expr = JsonPath::parse(&opts.path).map_err(|e| {
421 Error::rule_config(&spec.id, format!("invalid JSONPath {:?}: {e}", opts.path))
422 })?;
423 let re = Regex::new(&opts.matches).map_err(|e| {
424 Error::rule_config(&spec.id, format!("invalid regex {:?}: {e}", opts.matches))
425 })?;
426 Ok(Box::new(StructuredPathRule {
427 id: spec.id.clone(),
428 level: spec.level,
429 policy_url: spec.policy_url.clone(),
430 message: spec.message.clone(),
431 scope: Scope::from_paths_spec(paths)?,
432 literal_paths: extract_literal_paths(paths),
433 format,
434 path_expr,
435 path_src: opts.path,
436 op: Op::Matches(re),
437 if_present: opts.if_present,
438 }))
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
445
446 #[test]
449 fn build_rejects_missing_paths() {
450 let spec = spec_yaml(
451 "id: t\n\
452 kind: json_path_equals\n\
453 path: \"$.name\"\n\
454 equals: \"x\"\n\
455 level: error\n",
456 );
457 assert!(json_path_equals_build(&spec).is_err());
458 }
459
460 #[test]
461 fn build_rejects_invalid_jsonpath() {
462 let spec = spec_yaml(
463 "id: t\n\
464 kind: json_path_equals\n\
465 paths: \"package.json\"\n\
466 path: \"$..[invalid\"\n\
467 equals: \"x\"\n\
468 level: error\n",
469 );
470 assert!(json_path_equals_build(&spec).is_err());
471 }
472
473 #[test]
474 fn build_rejects_invalid_regex_in_matches() {
475 let spec = spec_yaml(
476 "id: t\n\
477 kind: json_path_matches\n\
478 paths: \"package.json\"\n\
479 path: \"$.version\"\n\
480 pattern: \"[unterminated\"\n\
481 level: error\n",
482 );
483 assert!(json_path_matches_build(&spec).is_err());
484 }
485
486 #[test]
489 fn json_path_equals_passes_when_value_matches() {
490 let spec = spec_yaml(
491 "id: t\n\
492 kind: json_path_equals\n\
493 paths: \"package.json\"\n\
494 path: \"$.name\"\n\
495 equals: \"demo\"\n\
496 level: error\n",
497 );
498 let rule = json_path_equals_build(&spec).unwrap();
499 let (tmp, idx) =
500 tempdir_with_files(&[("package.json", br#"{"name":"demo","version":"1.0.0"}"#)]);
501 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
502 assert!(v.is_empty(), "matching value should pass: {v:?}");
503 }
504
505 #[test]
506 fn json_path_equals_fires_on_mismatch() {
507 let spec = spec_yaml(
508 "id: t\n\
509 kind: json_path_equals\n\
510 paths: \"package.json\"\n\
511 path: \"$.name\"\n\
512 equals: \"demo\"\n\
513 level: error\n",
514 );
515 let rule = json_path_equals_build(&spec).unwrap();
516 let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"name":"other"}"#)]);
517 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
518 assert_eq!(v.len(), 1);
519 }
520
521 #[test]
522 fn json_path_equals_fires_on_missing_path() {
523 let spec = spec_yaml(
524 "id: t\n\
525 kind: json_path_equals\n\
526 paths: \"package.json\"\n\
527 path: \"$.name\"\n\
528 equals: \"demo\"\n\
529 level: error\n",
530 );
531 let rule = json_path_equals_build(&spec).unwrap();
532 let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.0"}"#)]);
533 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
534 assert_eq!(v.len(), 1, "missing path should fire");
535 }
536
537 #[test]
538 fn json_path_if_present_silent_on_missing() {
539 let spec = spec_yaml(
541 "id: t\n\
542 kind: json_path_equals\n\
543 paths: \"package.json\"\n\
544 path: \"$.name\"\n\
545 equals: \"demo\"\n\
546 if_present: true\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!(v.is_empty(), "if_present should silence: {v:?}");
553 }
554
555 #[test]
558 fn json_path_matches_passes_on_pattern_hit() {
559 let spec = spec_yaml(
560 "id: t\n\
561 kind: json_path_matches\n\
562 paths: \"package.json\"\n\
563 path: \"$.version\"\n\
564 matches: \"^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\"\n\
565 level: error\n",
566 );
567 let rule = json_path_matches_build(&spec).unwrap();
568 let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.2.3"}"#)]);
569 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
570 assert!(v.is_empty(), "matching version should pass: {v:?}");
571 }
572
573 #[test]
574 fn json_path_matches_fires_on_pattern_miss() {
575 let spec = spec_yaml(
576 "id: t\n\
577 kind: json_path_matches\n\
578 paths: \"package.json\"\n\
579 path: \"$.version\"\n\
580 matches: \"^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\"\n\
581 level: error\n",
582 );
583 let rule = json_path_matches_build(&spec).unwrap();
584 let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"v1.x"}"#)]);
585 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
586 assert_eq!(v.len(), 1);
587 }
588
589 #[test]
592 fn yaml_path_equals_passes_when_value_matches() {
593 let spec = spec_yaml(
594 "id: t\n\
595 kind: yaml_path_equals\n\
596 paths: \".github/workflows/*.yml\"\n\
597 path: \"$.name\"\n\
598 equals: \"CI\"\n\
599 level: error\n",
600 );
601 let rule = yaml_path_equals_build(&spec).unwrap();
602 let (tmp, idx) = tempdir_with_files(&[(
603 ".github/workflows/ci.yml",
604 b"name: CI\non: push\njobs: {}\n",
605 )]);
606 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
607 assert!(v.is_empty(), "matching name should pass: {v:?}");
608 }
609
610 #[test]
611 fn yaml_path_matches_uses_bracket_notation_for_dashed_keys() {
612 let spec = spec_yaml(
616 "id: t\n\
617 kind: yaml_path_matches\n\
618 paths: \"action.yml\"\n\
619 path: \"$.runs['using']\"\n\
620 matches: \"^node\\\\d+$\"\n\
621 level: error\n",
622 );
623 let rule = yaml_path_matches_build(&spec).unwrap();
624 let (tmp, idx) =
625 tempdir_with_files(&[("action.yml", b"runs:\n using: node20\n main: index.js\n")]);
626 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
627 assert!(v.is_empty(), "bracket notation should match: {v:?}");
628 }
629
630 #[test]
633 fn toml_path_equals_passes_when_value_matches() {
634 let spec = spec_yaml(
635 "id: t\n\
636 kind: toml_path_equals\n\
637 paths: \"Cargo.toml\"\n\
638 path: \"$.package.edition\"\n\
639 equals: \"2024\"\n\
640 level: error\n",
641 );
642 let rule = toml_path_equals_build(&spec).unwrap();
643 let (tmp, idx) = tempdir_with_files(&[(
644 "Cargo.toml",
645 b"[package]\nname = \"x\"\nedition = \"2024\"\n",
646 )]);
647 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
648 assert!(v.is_empty(), "matching edition should pass: {v:?}");
649 }
650
651 #[test]
652 fn toml_path_matches_fires_on_floating_version() {
653 let spec = spec_yaml(
655 "id: t\n\
656 kind: toml_path_matches\n\
657 paths: \"Cargo.toml\"\n\
658 path: \"$.dependencies.serde\"\n\
659 matches: \"^[~=]\"\n\
660 level: error\n",
661 );
662 let rule = toml_path_matches_build(&spec).unwrap();
663 let (tmp, idx) = tempdir_with_files(&[(
664 "Cargo.toml",
665 b"[package]\nname = \"x\"\n[dependencies]\nserde = \"1\"\n",
666 )]);
667 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
668 assert_eq!(v.len(), 1, "floating `serde = \"1\"` should fire");
669 }
670
671 #[test]
674 fn evaluate_fires_on_malformed_input() {
675 let spec = spec_yaml(
676 "id: t\n\
677 kind: json_path_equals\n\
678 paths: \"package.json\"\n\
679 path: \"$.name\"\n\
680 equals: \"x\"\n\
681 level: error\n",
682 );
683 let rule = json_path_equals_build(&spec).unwrap();
684 let (tmp, idx) = tempdir_with_files(&[("package.json", b"{not valid json")]);
685 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
686 assert_eq!(v.len(), 1, "malformed JSON should fire one violation");
687 }
688}