1use std::collections::{HashMap, HashSet};
9
10use crate::diag::{Diagnostic, Repair, RepairKind, Severity};
11use crate::ir::{Module, Node, NodeId, Value};
12
13pub const RULE_UNUSED_LETS: &str = "unused-lets";
15pub const RULE_DEPENDS_ON: &str = "depends-on";
16pub const RULE_COMPLEXITY: &str = "complexity";
17pub const RULE_HARDCODED_SECRETS: &str = "hardcoded-secrets";
18pub const RULE_DEPRECATED: &str = "deprecated";
19
20pub const ALL_RULES: &[&str] = &[
22 RULE_UNUSED_LETS,
23 RULE_DEPENDS_ON,
24 RULE_COMPLEXITY,
25 RULE_HARDCODED_SECRETS,
26 RULE_DEPRECATED,
27];
28
29const COMPLEXITY_LIMIT: u64 = 100;
31
32const ENTROPY_BITS: f64 = 4.5;
37const ENTROPY_MIN_LEN: usize = 20;
38
39#[derive(Debug, Clone, Default)]
41pub struct LintOptions {
42 pub disabled: HashSet<String>,
43}
44
45impl LintOptions {
46 pub fn with_disabled(names: &[String]) -> Result<Self, String> {
48 let mut disabled = HashSet::new();
49 for name in names {
50 if !ALL_RULES.contains(&name.as_str()) {
51 return Err(format!(
52 "unknown lint rule `{name}` (expected one of: {})",
53 ALL_RULES.join(", ")
54 ));
55 }
56 disabled.insert(name.clone());
57 }
58 Ok(Self { disabled })
59 }
60
61 fn enabled(&self, rule: &str) -> bool {
62 !self.disabled.contains(rule)
63 }
64}
65
66pub fn lint(module: &Module) -> Vec<Diagnostic> {
70 lint_with(module, &LintOptions::default())
71}
72
73pub fn lint_with(module: &Module, options: &LintOptions) -> Vec<Diagnostic> {
75 let mut out = Vec::new();
76 if options.enabled(RULE_UNUSED_LETS) {
77 out.extend(unused_lets(module));
78 }
79 if options.enabled(RULE_DEPENDS_ON) {
80 out.extend(depends_on(module));
81 }
82 if options.enabled(RULE_COMPLEXITY) {
83 out.extend(complexity(module));
84 }
85 if options.enabled(RULE_HARDCODED_SECRETS) {
86 out.extend(hardcoded_secrets(module));
87 }
88 if options.enabled(RULE_DEPRECATED) {
89 out.extend(deprecated(module));
90 }
91 out
92}
93
94fn warning(
95 module: &Module,
96 code: &str,
97 message: String,
98 path: Option<String>,
99 node: Option<NodeId>,
100) -> Diagnostic {
101 Diagnostic {
102 severity: Severity::Warning,
103 code: code.into(),
104 message,
105 path,
106 span: node.and_then(|id| module.node_spans.get(&id).cloned()),
107 repair: None,
108 confidence: 1.0,
109 file: node.and_then(|id| module.node_files.get(&id).cloned()),
110 }
111}
112
113fn ref_targets(module: &Module, id: NodeId) -> HashSet<NodeId> {
117 let mut targets = HashSet::new();
118 let mut stack = vec![id];
119 let mut seen = HashSet::new();
120 while let Some(nid) = stack.pop() {
121 if !seen.insert(nid) {
122 continue;
123 }
124 match module.get(nid) {
125 Some(Node::Ref(target)) => {
126 targets.insert(*target);
127 }
128 Some(Node::Table { fields }) => {
129 stack.extend(fields.values().copied());
130 }
131 Some(Node::Array { elements }) => {
132 stack.extend(elements.iter().copied());
133 }
134 Some(Node::Binary { lhs, rhs, .. }) => {
135 stack.push(*lhs);
136 stack.push(*rhs);
137 }
138 Some(Node::Unary { expr, .. }) => {
139 stack.push(*expr);
140 }
141 Some(Node::Interpolate { parts }) => {
142 for part in parts {
143 if let crate::ir::InterpPart::Expr(e) = part {
144 stack.push(*e);
145 }
146 }
147 }
148 Some(Node::Semantic { inner, .. }) => {
149 stack.push(*inner);
150 }
151 Some(Node::Env {
152 default: Some(def), ..
153 }) => {
154 stack.push(*def);
155 }
156 Some(Node::Secret { locator }) => {
157 stack.push(*locator);
158 }
159 _ => {}
160 }
161 }
162 targets
163}
164
165fn unused_lets(module: &Module) -> Vec<Diagnostic> {
167 let mut used = HashSet::new();
173 for node in module.arena.iter() {
174 if let Node::Ref(target) = node {
175 used.insert(*target);
176 }
177 }
178 let mut names: Vec<&String> = module.bindings.keys().collect();
181 names.sort();
182 let mut out = Vec::new();
183 for name in names {
184 let id = module.bindings[name];
185 if !used.contains(&id) {
186 out.push(warning(
187 module,
188 "L001",
189 format!("unused let binding '{name}'"),
190 Some(name.clone()),
191 Some(id),
192 ));
193 }
194 }
195 out
196}
197
198fn depends_on(module: &Module) -> Vec<Diagnostic> {
200 let paths = module.value_paths();
201 let mut scopes: HashMap<NodeId, String> = HashMap::new();
203 if let Some(root) = module.root {
204 let mut stack = vec![(root, String::new())];
205 while let Some((id, path)) = stack.pop() {
206 if module.depends_on.contains_key(&id) {
207 scopes.insert(id, path.clone());
208 }
209 if let Some(Node::Table { fields }) = module.get(id) {
210 for (key, child) in fields {
211 let child_path = if path.is_empty() {
212 key.clone()
213 } else {
214 format!("{path}.{key}")
215 };
216 stack.push((*child, child_path));
217 }
218 }
219 }
220 }
221 let mut entries: Vec<(NodeId, String)> = scopes.into_iter().collect();
223 entries.sort_by(|a, b| a.1.cmp(&b.1));
224 let mut out = Vec::new();
225 for (table_id, scope_path) in entries {
226 let dep = &module.depends_on[&table_id];
227 let declared: HashSet<&str> = dep.paths.iter().map(String::as_str).collect();
228 let mut referenced: HashSet<String> = HashSet::new();
231 if let Some(Node::Table { fields }) = module.get(table_id) {
232 for child in fields.values() {
233 for target in ref_targets(module, *child) {
234 if let Some(name) = paths.get(&target) {
235 referenced.insert(name.clone());
236 }
237 }
238 }
239 }
240 let span_node = Some(table_id);
241 if dep.paths.is_empty() {
242 if !referenced.is_empty() {
243 let mut missing: Vec<&String> = referenced.iter().collect();
244 missing.sort();
245 for name in missing {
246 out.push(missing_entry(module, &scope_path, span_node, name));
247 }
248 }
249 continue;
250 }
251 for path in &dep.paths {
252 if path == &scope_path
253 || (!scope_path.is_empty() && path.starts_with(&format!("{scope_path}.")))
254 {
255 out.push(warning(
256 module,
257 "L002",
258 format!("unnecessary depends_on entry '{path}' (own scope)"),
259 Some(scope_path.clone()),
260 span_node,
261 ));
262 } else if module.resolve_value_path(path).is_none() {
263 out.push(warning(
264 module,
265 "L002",
266 format!("dangling depends_on path '{path}'"),
267 Some(scope_path.clone()),
268 span_node,
269 ));
270 }
271 }
272 let mut missing: Vec<&String> = referenced
273 .iter()
274 .filter(|name| !declared.contains(name.as_str()))
275 .collect();
276 missing.sort();
277 for name in missing {
278 out.push(missing_entry(module, &scope_path, span_node, name));
279 }
280 }
281 out
282}
283
284fn missing_entry(
288 module: &Module,
289 scope_path: &str,
290 span_node: Option<NodeId>,
291 name: &str,
292) -> Diagnostic {
293 let mut diag = warning(
294 module,
295 "L002",
296 format!("missing depends_on entry for '{name}'"),
297 Some(scope_path.to_string()),
298 span_node,
299 );
300 diag.repair = Some(Repair {
301 original: String::new(),
302 suggested: name.to_string(),
303 kind: RepairKind::Other("add_depends_on".to_string()),
304 safe: true,
305 });
306 diag
307}
308
309fn operator_count(module: &Module, id: NodeId) -> u64 {
313 let mut count = 0u64;
314 let mut stack = vec![id];
315 let mut seen = HashSet::new();
316 while let Some(nid) = stack.pop() {
317 if !seen.insert(nid) {
318 continue;
319 }
320 match module.get(nid) {
321 Some(Node::Binary { lhs, rhs, .. }) => {
322 count += 1;
323 stack.push(*lhs);
324 stack.push(*rhs);
325 }
326 Some(Node::Unary { expr, .. }) => {
327 count += 1;
328 stack.push(*expr);
329 }
330 Some(Node::Interpolate { parts }) => {
331 for part in parts {
332 if let crate::ir::InterpPart::Expr(e) = part {
333 count += 1;
334 stack.push(*e);
335 }
336 }
337 }
338 Some(Node::Ref(_)) | Some(Node::Ident(_)) => {
339 count += 1;
340 }
341 Some(Node::Env { default, .. }) => {
342 count += 1;
345 if let Some(def) = default {
346 stack.push(*def);
347 }
348 }
349 Some(Node::Secret { locator }) => {
350 count += 1;
352 stack.push(*locator);
353 }
354 Some(Node::Table { fields }) => {
355 stack.extend(fields.values().copied());
356 }
357 Some(Node::Array { elements }) => {
358 stack.extend(elements.iter().copied());
359 }
360 Some(Node::Semantic { inner, .. }) => {
361 stack.push(*inner);
362 }
363 _ => {}
364 }
365 }
366 count
367}
368
369fn complexity(module: &Module) -> Vec<Diagnostic> {
373 let mut out = Vec::new();
374 let check = |path: String, id: NodeId, out: &mut Vec<Diagnostic>| {
375 let count = operator_count(module, id);
376 if count > COMPLEXITY_LIMIT {
377 out.push(warning(
378 module,
379 "L003",
380 format!("expression complexity {count} exceeds limit {COMPLEXITY_LIMIT}"),
381 Some(path),
382 Some(id),
383 ));
384 }
385 };
386 let mut names: Vec<&String> = module.bindings.keys().collect();
387 names.sort();
388 for name in names {
389 check(name.clone(), module.bindings[name], &mut out);
390 }
391 if let Some(root) = module.root {
392 if let Some(Node::Table { fields }) = module.get(root).cloned() {
393 for (key, id) in &fields {
394 check(key.clone(), *id, &mut out);
395 }
396 }
397 }
398 out
399}
400
401fn is_credential_name(key: &str) -> bool {
403 const SIGNALS: &[&str] = &[
404 "password",
405 "passwd",
406 "secret",
407 "token",
408 "apikey",
409 "privatekey",
410 "auth",
411 "credential",
412 "accesskey",
413 "secretkey",
414 ];
415 let normalized: String = key
416 .to_lowercase()
417 .chars()
418 .filter(|c| *c != '_' && *c != '-')
419 .collect();
420 SIGNALS.iter().any(|signal| {
421 normalized == *signal || normalized.starts_with(signal) || normalized.ends_with(signal)
422 })
423}
424
425fn is_placeholder(value: &str) -> bool {
432 if has_url_scheme(value) {
433 return true;
434 }
435 matches!(
436 value.to_lowercase().as_str(),
437 "changeme"
438 | "password"
439 | "xxx"
440 | "test"
441 | "example"
442 | "placeholder"
443 | "redacted"
444 | "***"
445 | "***secret***"
446 | ""
447 )
448}
449
450fn has_url_scheme(value: &str) -> bool {
452 let bytes = value.as_bytes();
453 let mut i = 0;
454 if bytes.first().is_some_and(|c| c.is_ascii_alphabetic()) {
455 i += 1;
456 while i < bytes.len()
457 && (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'+' | b'-' | b'.'))
458 {
459 i += 1;
460 }
461 }
462 value.len() > i + 2 && &value[i..i + 3] == "://"
463}
464
465fn shannon_entropy(value: &str) -> f64 {
469 use std::collections::BTreeMap;
470 let mut counts = BTreeMap::new();
471 let mut total = 0usize;
472 for c in value.chars() {
473 *counts.entry(c).or_insert(0usize) += 1;
474 total += 1;
475 }
476 if total == 0 {
477 return 0.0;
478 }
479 counts
480 .values()
481 .map(|count| {
482 let p = *count as f64 / total as f64;
483 -p * p.log2()
484 })
485 .sum()
486}
487
488fn hardcoded_secrets(module: &Module) -> Vec<Diagnostic> {
491 let mut out = Vec::new();
492 let mut literals: Vec<(String, NodeId, String)> = Vec::new();
496 if let Some(root) = module.root {
498 let mut stack = vec![(root, String::new())];
499 while let Some((id, path)) = stack.pop() {
500 match module.get(id) {
501 Some(Node::Table { fields }) => {
502 let mut entries: Vec<_> = fields.iter().collect();
504 entries.reverse();
505 for (key, child) in entries {
506 let child_path = if path.is_empty() {
507 key.clone()
508 } else {
509 format!("{path}.{key}")
510 };
511 match module.get(*child) {
512 Some(Node::Literal(Value::String(text))) => {
513 literals.push((child_path, *child, text.clone()));
514 }
515 _ => stack.push((*child, child_path)),
516 }
517 }
518 }
519 Some(Node::Array { elements }) => {
520 let mut indexed: Vec<_> = elements.iter().enumerate().collect();
521 indexed.reverse();
522 for (i, child) in indexed {
523 let child_path = format!("{path}[{i}]");
524 match module.get(*child) {
525 Some(Node::Literal(Value::String(text))) => {
526 literals.push((child_path, *child, text.clone()));
527 }
528 _ => stack.push((*child, child_path)),
529 }
530 }
531 }
532 _ => {}
533 }
534 }
535 }
536 let mut names: Vec<&String> = module.bindings.keys().collect();
537 names.sort();
538 for name in names {
539 if let Some(Node::Literal(Value::String(text))) = module.get(module.bindings[name]) {
540 literals.push((name.clone(), module.bindings[name], text.clone()));
541 }
542 }
543 for (path, id, text) in literals {
544 let key = path.rsplit('.').next().unwrap_or(&path);
547 let key = key.split('[').next().unwrap_or(key);
548 let named = is_credential_name(key) && !is_placeholder(&text);
549 let entropic =
550 text.chars().count() >= ENTROPY_MIN_LEN && shannon_entropy(&text) >= ENTROPY_BITS;
551 if named || entropic {
552 out.push(warning(
553 module,
554 "L004",
555 format!(
556 "possible hard-coded secret in key '{path}' (use secret() or secret.env())"
557 ),
558 Some(path),
559 Some(id),
560 ));
561 }
562 }
563 out
564}
565
566fn deprecated(module: &Module) -> Vec<Diagnostic> {
570 let Some(schema_id) = module.schema else {
571 return Vec::new();
572 };
573 let Some(root) = module.root else {
574 return Vec::new();
575 };
576 let mut out = Vec::new();
577 let mut stack = vec![(schema_id, root, String::new())];
579 while let Some((schema, value, path)) = stack.pop() {
580 let (
581 Some(Node::SchemaTable {
582 fields: schema_fields,
583 ..
584 }),
585 Some(Node::Table {
586 fields: value_fields,
587 }),
588 ) = (module.get(schema), module.get(value))
589 else {
590 continue;
591 };
592 let mut entries: Vec<_> = schema_fields.iter().collect();
595 entries.reverse();
596 for (name, field_id) in entries {
597 let child_path = if path.is_empty() {
598 name.clone()
599 } else {
600 format!("{path}.{name}")
601 };
602 let Some(value_id) = value_fields.get(name).copied() else {
603 continue;
604 };
605 match module.get(*field_id) {
606 Some(Node::SchemaField { field }) => {
607 if let Some(reason) = &field.deprecated {
608 let message = if reason.is_empty() {
609 format!("deprecated field '{child_path}' is still used")
610 } else {
611 format!("deprecated field '{child_path}' is still used: {reason}")
612 };
613 out.push(warning(
614 module,
615 "L005",
616 message,
617 Some(child_path),
618 Some(value_id),
619 ));
620 }
621 }
622 Some(Node::SchemaTable { .. }) => {
623 stack.push((*field_id, value_id, child_path));
624 }
625 Some(Node::SchemaArray { element }) => {
626 if let Some(Node::Array { elements }) = module.get(value_id) {
627 for (i, element_id) in elements.iter().enumerate() {
628 stack.push((*element, *element_id, format!("{child_path}[{i}]")));
629 }
630 }
631 }
632 _ => {}
633 }
634 }
635 }
636 out.reverse();
638 out
639}