1use std::collections::HashMap;
5
6use anyhow::{bail, Context, Result};
7use globset::{Glob, GlobMatcher};
8use serde::Serialize;
9
10use crate::ledger::{Entry, Kind, Status, Trust};
11use crate::manifests::PackageRow;
12use crate::model::{Edge, EdgeKind, Symbol};
13
14#[derive(Debug, Clone)]
15pub struct Selector {
16 path: GlobMatcher,
17 name: Option<GlobMatcher>,
18}
19
20impl Selector {
21 fn parse(s: &str) -> Result<Selector> {
22 let (path, name) = match s.split_once("::") {
23 Some((p, n)) => (p, Some(glob(n)?)),
24 None => (s, None),
25 };
26 Ok(Selector {
27 path: glob(path)?,
28 name,
29 })
30 }
31
32 fn matches(&self, s: &Symbol) -> bool {
33 self.path.is_match(format!("{}/{}", s.repo.0, s.file_path))
34 && self.name.as_ref().is_none_or(|n| n.is_match(&s.name))
35 }
36}
37
38#[derive(Debug, Clone)]
39pub enum Rule {
40 Edge {
41 kind: Option<EdgeKind>,
42 from: Selector,
43 to: Selector,
44 },
45 Dep {
46 name: GlobMatcher,
47 version: Option<GlobMatcher>,
48 },
49}
50
51#[derive(Debug, Clone, Serialize)]
53pub struct Violation {
54 pub repo: String,
55 pub path: String,
56 pub line: u32,
57 pub what: String,
58}
59
60#[derive(Debug, Clone, Serialize)]
62pub struct Finding {
63 pub record: String,
64 pub title: String,
65 pub rule: String,
66 pub severity: String,
67 pub violations: Vec<Violation>,
68}
69
70fn glob(s: &str) -> Result<GlobMatcher> {
71 Ok(Glob::new(s)
72 .with_context(|| format!("bad glob `{s}`"))?
73 .compile_matcher())
74}
75
76fn edge_kind(s: &str) -> Option<EdgeKind> {
77 use EdgeKind::*;
78 [
79 Contains,
80 Imports,
81 Calls,
82 Instantiates,
83 Inherits,
84 Implements,
85 References,
86 Defines,
87 Uses,
88 ParticipatesIn,
89 Serves,
90 Consumes,
91 Publishes,
92 Subscribes,
93 Reads,
94 Writes,
95 ]
96 .into_iter()
97 .find(|k| k.as_str() == s)
98}
99
100impl Rule {
101 pub fn parse(text: &str) -> Result<Rule> {
102 let w: Vec<&str> = text.split_whitespace().collect();
103 match w.as_slice() {
104 ["forbid", "dep", name] => Ok(Rule::Dep {
105 name: glob(name)?,
106 version: None,
107 }),
108 ["forbid", "dep", name, v] => Ok(Rule::Dep {
109 name: glob(name)?,
110 version: Some(glob(v.trim_start_matches(['=', '^', '~']))?),
111 }),
112 ["forbid", "from", a, "to", b] => Ok(Rule::Edge {
113 kind: None,
114 from: Selector::parse(a)?,
115 to: Selector::parse(b)?,
116 }),
117 ["forbid", k, "from", a, "to", b] => Ok(Rule::Edge {
118 kind: Some(edge_kind(k).with_context(|| format!("unknown edge kind `{k}`"))?),
119 from: Selector::parse(a)?,
120 to: Selector::parse(b)?,
121 }),
122 _ => bail!(
123 "expected `forbid [<edge-kind>] from <glob>[::<name>] to <glob>[::<name>]` \
124 or `forbid dep <package> [<version>]`"
125 ),
126 }
127 }
128
129 pub fn violations(
130 &self,
131 by_id: &HashMap<String, &Symbol>,
132 edges: &[Edge],
133 packages: &[PackageRow],
134 ) -> Vec<Violation> {
135 let mut out = Vec::new();
136 match self {
137 Rule::Edge { kind, from, to } => {
138 for e in edges {
139 let wanted = match kind {
140 Some(k) => &e.kind == k,
141 None => e.kind != EdgeKind::Contains,
142 };
143 if !wanted {
144 continue;
145 }
146 let (Some(s), Some(d)) =
147 (by_id.get(&e.src.render()), by_id.get(&e.dst.render()))
148 else {
149 continue;
150 };
151 if from.matches(s) && to.matches(d) {
152 out.push(Violation {
153 repo: s.repo.0.clone(),
154 path: s.file_path.clone(),
155 line: s.range.start.line,
156 what: if d.file_path.is_empty() {
157 format!("`{}` {} `{}`", s.name, e.kind, d.name)
158 } else {
159 format!(
160 "`{}` {} `{}` ({}/{})",
161 s.name, e.kind, d.name, d.repo.0, d.file_path
162 )
163 },
164 });
165 }
166 }
167 }
168 Rule::Dep { name, version } => {
169 for p in packages
170 .iter()
171 .filter(|p| !p.publishes && name.is_match(&p.name))
172 {
173 let hit = match version {
174 None => true,
175 Some(v) => [&p.locked, &p.version].into_iter().flatten().any(|x| {
176 v.is_match(x.trim_start_matches(['=', '^', '~', '>', '<', ' ']))
177 }),
178 };
179 if hit {
180 out.push(Violation {
181 repo: p.repo.clone(),
182 path: p.manifest.clone(),
183 line: 0,
184 what: format!(
185 "depends on {} {}",
186 p.name,
187 p.locked.as_deref().or(p.version.as_deref()).unwrap_or("*")
188 ),
189 });
190 }
191 }
192 }
193 }
194 out.sort_by(|a, b| (&a.repo, &a.path, a.line).cmp(&(&b.repo, &b.path, b.line)));
195 out.dedup_by(|a, b| a.repo == b.repo && a.path == b.path && a.what == b.what);
196 out
197 }
198}
199
200pub fn revivals<'e>(entries: &'e [Entry], packages: &[PackageRow]) -> Vec<(&'e Entry, String)> {
203 let mut out = Vec::new();
204 for e in entries {
205 if e.record.kind != Kind::Attempt
206 || matches!(e.status, Status::Retracted | Status::Superseded)
207 {
208 continue;
209 }
210 let Some(dep) = e.record.fields.get("dep").and_then(|v| v.as_str()) else {
211 continue;
212 };
213 let Ok(Rule::Dep {
214 name,
215 version: Some(v),
216 }) = Rule::parse(&format!("forbid dep {dep}"))
217 else {
218 continue;
219 };
220 let pinned = Rule::Dep {
221 name: name.clone(),
222 version: Some(v),
223 };
224 let none = HashMap::new();
225 let used = Rule::Dep {
226 name,
227 version: None,
228 }
229 .violations(&none, &[], packages);
230 if !used.is_empty() && pinned.violations(&none, &[], packages).is_empty() {
231 let now: std::collections::BTreeSet<&str> = used
232 .iter()
233 .filter_map(|u| u.what.rsplit(' ').next())
234 .collect();
235 out.push((
236 e,
237 format!(
238 "failed under `{dep}`; now on {}",
239 now.into_iter().collect::<Vec<_>>().join(", ")
240 ),
241 ));
242 }
243 }
244 out
245}
246
247pub fn rule_of(e: &Entry) -> Option<&str> {
249 if e.record.kind != Kind::Constraint
250 || matches!(e.status, Status::Retracted | Status::Superseded)
251 {
252 return None;
253 }
254 e.record.fields.get("check")?.as_str()
255}
256
257pub fn run(
259 entries: &[Entry],
260 symbols: &[Symbol],
261 edges: &[Edge],
262 packages: &[PackageRow],
263) -> Vec<Finding> {
264 let by_id: HashMap<String, &Symbol> = symbols.iter().map(|s| (s.id.render(), s)).collect();
265 entries
266 .iter()
267 .filter_map(|e| {
268 let rule = rule_of(e)?;
269 let violations = match Rule::parse(rule) {
270 Ok(r) => r.violations(&by_id, edges, packages),
271 Err(err) => vec![Violation {
272 repo: String::new(),
273 path: String::new(),
274 line: 0,
275 what: format!("rule does not parse: {err:#}"),
276 }],
277 };
278 Some(Finding {
279 record: e.record.id.clone(),
280 title: e.record.title.clone(),
281 rule: rule.to_string(),
282 severity: e
283 .record
284 .fields
285 .get("severity")
286 .and_then(|v| v.as_str())
287 .unwrap_or("must")
288 .to_string(),
289 violations,
290 })
291 })
292 .collect()
293}
294
295pub fn promote(entries: &mut [Entry], findings: &[Finding]) {
297 for e in entries.iter_mut() {
298 if findings
299 .iter()
300 .any(|f| f.record == e.record.id && f.violations.is_empty())
301 {
302 e.trust = Trust::T4;
303 }
304 }
305}
306
307pub fn markdown(findings: &[Finding], only: Option<&[String]>) -> String {
309 let mut o = String::new();
310 for f in findings {
311 let shown: Vec<&Violation> = f
312 .violations
313 .iter()
314 .filter(|v| only.is_none_or(|o| o.contains(&format!("{}/{}", v.repo, v.path))))
315 .collect();
316 let state = if f.violations.is_empty() {
317 "passes".to_string()
318 } else {
319 format!("{} violation(s)", f.violations.len())
320 };
321 if only.is_some() && shown.is_empty() {
322 continue;
323 }
324 o.push_str(&format!(
325 "- [{} · {state}] {} — `{}` · id `{}`\n",
326 f.severity,
327 f.title,
328 f.rule,
329 &f.record[..f.record.len().min(13)]
330 ));
331 for v in shown.iter().take(20) {
332 let at = if v.line > 0 {
333 format!(":{}", v.line)
334 } else {
335 String::new()
336 };
337 o.push_str(&format!(" - {}/{}{at} {}\n", v.repo, v.path, v.what));
338 }
339 if shown.len() > 20 {
340 o.push_str(&format!(" - … {} more\n", shown.len() - 20));
341 }
342 }
343 o
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn parses_rules() {
352 assert!(Rule::parse("forbid calls from svc-a/handlers/** to */db/*.py::raw_*").is_ok());
353 assert!(Rule::parse("forbid from a/** to b/**").is_ok());
354 assert!(Rule::parse("forbid dep lance =1.0.1").is_ok());
355 assert!(Rule::parse("forbid frobs from a to b").is_err());
356 assert!(Rule::parse("allow everything").is_err());
357 }
358
359 #[test]
360 fn edge_rule_matches_path_and_name() {
361 use crate::model::{GlobalSymbolId, Language, Position, Range, RepoId, SymbolKind};
362 let at = Position { line: 1, column: 0 };
363 let sym = |repo: &str, path: &str, name: &str| Symbol {
364 id: GlobalSymbolId {
365 scheme: "comindly-treesitter".into(),
366 package_manager: ".".into(),
367 package: repo.into(),
368 version: ".".into(),
369 descriptor: format!("{path}/{name}()."),
370 },
371 name: name.into(),
372 kind: SymbolKind::Function,
373 language: Language::Python,
374 repo: RepoId(repo.into()),
375 file_path: path.into(),
376 range: Range { start: at, end: at },
377 signature: None,
378 docstring: None,
379 fingerprint: None,
380 };
381 let a = sym("svc", "app/handlers/user.py", "get_user");
382 let b = sym("svc", "app/db/raw.py", "raw_query");
383 let edge = Edge {
384 src: a.id.clone(),
385 dst: b.id.clone(),
386 kind: EdgeKind::Calls,
387 confidence: 1.0,
388 cross_repo: false,
389 };
390 let by_id: HashMap<String, &Symbol> =
391 [&a, &b].into_iter().map(|s| (s.id.render(), s)).collect();
392 let hit = |r: &str| {
393 Rule::parse(r)
394 .unwrap()
395 .violations(&by_id, std::slice::from_ref(&edge), &[])
396 .len()
397 };
398 assert_eq!(hit("forbid calls from svc/app/handlers/** to **::raw_*"), 1);
399 assert_eq!(hit("forbid from svc/app/handlers/** to svc/app/db/*"), 1);
400 assert_eq!(hit("forbid imports from svc/** to svc/**"), 0);
401 assert_eq!(hit("forbid calls from svc/app/db/** to **"), 0);
402 }
403
404 #[test]
405 fn revival_when_pinned_version_is_gone() {
406 use crate::ledger::{Anchor, AuthorKind, Provenance, Record};
407 let row = |l: &str| PackageRow {
408 repo: "r".into(),
409 ecosystem: "cargo".into(),
410 name: "lance".into(),
411 publishes: false,
412 version: Some(l.into()),
413 locked: Some(l.into()),
414 manifest: "Cargo.toml".into(),
415 };
416 let mut rec = Record::new(
417 Kind::Attempt,
418 "lance fragment reuse",
419 Vec::<Anchor>::new(),
420 Provenance {
421 author: AuthorKind::Human,
422 actor: "a".into(),
423 source: "t".into(),
424 agent: None,
425 session: None,
426 name: None,
427 teams: Vec::new(),
428 },
429 );
430 rec.fields.insert("dep".into(), "lance =1.0.1".into());
431 let e = vec![Entry {
432 record: rec,
433 status: Status::Active,
434 trust: Trust::T3,
435 current: Vec::new(),
436 related: Vec::new(),
437 verified: None,
438 by: String::new(),
439 history: Vec::new(),
440 retired: None,
441 outside: Vec::new(),
442 vouched: Vec::new(),
443 alike: None,
444 }];
445 assert!(revivals(&e, &[row("1.0.1")]).is_empty());
446 assert_eq!(revivals(&e, &[row("1.3.0")]).len(), 1);
447 assert!(revivals(&e, &[]).is_empty());
448 }
449
450 #[test]
451 fn dep_rule_matches_locked_or_declared() {
452 let row = |v: &str, l: Option<&str>| PackageRow {
453 repo: "r".into(),
454 ecosystem: "cargo".into(),
455 name: "lance".into(),
456 publishes: false,
457 version: Some(v.into()),
458 locked: l.map(Into::into),
459 manifest: "Cargo.toml".into(),
460 };
461 let r = Rule::parse("forbid dep lance =1.0.1").unwrap();
462 let none = HashMap::new();
463 assert_eq!(r.violations(&none, &[], &[row("=1.0.1", None)]).len(), 1);
464 assert_eq!(
465 r.violations(&none, &[], &[row("1.0", Some("1.0.1"))]).len(),
466 1
467 );
468 assert!(r
469 .violations(&none, &[], &[row("1.3", Some("1.3.2"))])
470 .is_empty());
471 }
472}