1use std::fmt;
4use std::path::{Component, Path};
5
6use fallow_config::{ResolvedBoundaryConfig, ResolvedConfig, RulePackRule, RulePackRuleKind};
7use fallow_types::guard::{
8 GuardBoundary, GuardFileReport, GuardPolicyRule, GuardReport, GuardSeverities, GuardZone,
9};
10use rustc_hash::FxHashSet;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum GuardError {
15 OutsideRoot(String),
17}
18
19impl fmt::Display for GuardError {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 match self {
22 Self::OutsideRoot(path) => write!(f, "guard target is outside project root: {path}"),
23 }
24 }
25}
26
27impl std::error::Error for GuardError {}
28
29pub fn build_guard_report(
39 config: &ResolvedConfig,
40 files: &[String],
41) -> Result<GuardReport, GuardError> {
42 let mut reports = Vec::with_capacity(files.len());
43 for file in files {
44 reports.push(build_file_report(config, file)?);
45 }
46 Ok(GuardReport { files: reports })
47}
48
49fn build_file_report(config: &ResolvedConfig, input: &str) -> Result<GuardFileReport, GuardError> {
50 let rel_path = normalize_target_path(config, input)?;
51 let full_path = config.root.join(&rel_path);
52 let rules = config.resolve_rules_for_path(&full_path);
53 let zone_name = config.boundaries.classify_zone(&rel_path);
54 let zone = zone_name.and_then(|name| guard_zone(&config.boundaries, name));
55 let notes = guard_notes(config, zone_name);
56
57 Ok(GuardFileReport {
58 exists: full_path.exists(),
59 boundary: guard_boundary(&config.boundaries, &rel_path, zone_name),
60 policy_rules: guard_policy_rules(config, &rel_path, rules.policy_violation),
61 severities: GuardSeverities {
62 boundary_violation: rules.boundary_violation.to_string(),
63 policy_violation: rules.policy_violation.to_string(),
64 },
65 path: rel_path,
66 zone,
67 notes,
68 })
69}
70
71fn normalize_target_path(config: &ResolvedConfig, input: &str) -> Result<String, GuardError> {
72 let normalized = input.replace('\\', "/");
73 let path = Path::new(&normalized);
74 if looks_windows_absolute(&normalized) && !path.is_absolute() {
75 return Err(GuardError::OutsideRoot(input.to_string()));
76 }
77 let relative = if path.is_absolute() {
78 path.strip_prefix(&config.root)
79 .map_err(|_| GuardError::OutsideRoot(input.to_string()))?
80 } else {
81 path
82 };
83 normalize_relative_path(relative, input)
84}
85
86fn looks_windows_absolute(path: &str) -> bool {
87 let bytes = path.as_bytes();
88 bytes.len() >= 3 && bytes[1] == b':' && bytes[2] == b'/'
89}
90
91fn normalize_relative_path(path: &Path, original: &str) -> Result<String, GuardError> {
92 let mut parts = Vec::new();
93 for component in path.components() {
94 match component {
95 Component::CurDir => {}
96 Component::Normal(part) => parts.push(part.to_string_lossy().replace('\\', "/")),
97 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
98 return Err(GuardError::OutsideRoot(original.to_string()));
99 }
100 }
101 }
102 Ok(parts.join("/"))
103}
104
105fn guard_zone(boundaries: &ResolvedBoundaryConfig, name: &str) -> Option<GuardZone> {
106 boundaries
107 .zones
108 .iter()
109 .find(|zone| zone.name == name)
110 .map(|zone| GuardZone {
111 name: zone.name.clone(),
112 patterns: zone.patterns.clone(),
113 })
114}
115
116fn guard_boundary(
117 boundaries: &ResolvedBoundaryConfig,
118 rel_path: &str,
119 zone_name: Option<&str>,
120) -> GuardBoundary {
121 let configured = boundaries_configured(boundaries);
122 let coverage_required = zone_name.is_none()
123 && boundaries.coverage.require_all_files
124 && !boundaries.allows_unmatched(rel_path);
125
126 let Some(zone_name) = zone_name else {
127 return GuardBoundary {
128 configured,
129 unrestricted: true,
130 allowed_zones: Vec::new(),
131 allowed_type_only_zones: Vec::new(),
132 forbidden_calls: Vec::new(),
133 coverage_required,
134 };
135 };
136
137 let forbidden_calls = boundaries
138 .calls_forbidden_by_zone
139 .get(zone_name)
140 .cloned()
141 .unwrap_or_default();
142 let Some(rule) = boundaries
143 .rules
144 .iter()
145 .find(|rule| rule.from_zone == zone_name)
146 else {
147 return GuardBoundary {
148 configured,
149 unrestricted: true,
150 allowed_zones: Vec::new(),
151 allowed_type_only_zones: Vec::new(),
152 forbidden_calls,
153 coverage_required,
154 };
155 };
156
157 let mut allowed_zones = vec![zone_name.to_string()];
158 allowed_zones.extend(rule.allowed_zones.iter().cloned());
159 allowed_zones.sort();
160 allowed_zones.dedup();
161
162 GuardBoundary {
163 configured,
164 unrestricted: false,
165 allowed_zones,
166 allowed_type_only_zones: rule.allow_type_only_zones.clone(),
167 forbidden_calls,
168 coverage_required,
169 }
170}
171
172fn guard_notes(config: &ResolvedConfig, zone_name: Option<&str>) -> Vec<String> {
173 let mut notes = Vec::new();
174 if boundaries_configured(&config.boundaries) && zone_name.is_none() {
175 notes.push("Files outside every zone are unrestricted for boundary checks.".to_string());
176 }
177 if !boundaries_configured(&config.boundaries) && config.rule_packs.is_empty() {
178 notes.push("No boundary zones or rule packs are configured.".to_string());
179 }
180 if zone_name.is_some() {
181 notes.push("Same-zone imports are always allowed.".to_string());
182 }
183 notes
184}
185
186fn boundaries_configured(boundaries: &ResolvedBoundaryConfig) -> bool {
187 !boundaries.zones.is_empty() || !boundaries.logical_groups.is_empty()
188}
189
190fn guard_policy_rules(
191 config: &ResolvedConfig,
192 rel_path: &str,
193 master_severity: fallow_config::Severity,
194) -> Vec<GuardPolicyRule> {
195 if master_severity == fallow_config::Severity::Off {
196 return Vec::new();
197 }
198
199 rules_applying_to_path(config, rel_path)
200 .into_iter()
201 .filter_map(|(pack, rule)| guard_policy_rule(pack, rule, master_severity))
202 .collect()
203}
204
205fn rules_applying_to_path<'a>(
206 config: &'a ResolvedConfig,
207 rel_path: &str,
208) -> Vec<(&'a str, &'a RulePackRule)> {
209 let zone = config.boundaries.classify_zone(rel_path);
210 config
211 .rule_packs
212 .iter()
213 .flat_map(|pack| {
214 pack.rules
215 .iter()
216 .filter(move |rule| {
217 raw_rule_scope_applies(rule, &config.boundaries, rel_path, zone)
218 })
219 .map(|rule| (pack.name.as_str(), rule))
220 })
221 .collect()
222}
223
224fn raw_rule_scope_applies(
225 rule: &RulePackRule,
226 boundaries: &ResolvedBoundaryConfig,
227 relative: &str,
228 zone: Option<&str>,
229) -> bool {
230 let files = compile_scope_globs(&rule.files);
231 let exclude = compile_scope_globs(&rule.exclude);
232 let zones = rule.zones.iter().cloned().collect();
233 let zone = zone.or_else(|| boundaries.classify_zone(relative));
234 compiled_scope_applies(&files, &exclude, &zones, relative, zone)
235}
236
237fn compile_scope_globs(patterns: &[String]) -> Vec<globset::GlobMatcher> {
238 patterns
239 .iter()
240 .filter_map(|pattern| globset::Glob::new(pattern).ok())
241 .map(|glob| glob.compile_matcher())
242 .collect()
243}
244
245fn compiled_scope_applies(
246 files: &[globset::GlobMatcher],
247 exclude: &[globset::GlobMatcher],
248 zones: &FxHashSet<String>,
249 relative: &str,
250 zone: Option<&str>,
251) -> bool {
252 (files.is_empty() || files.iter().any(|matcher| matcher.is_match(relative)))
253 && !exclude.iter().any(|matcher| matcher.is_match(relative))
254 && (zones.is_empty() || zone.is_some_and(|zone| zones.contains(zone)))
255}
256
257fn guard_policy_rule(
258 pack: &str,
259 rule: &RulePackRule,
260 master_severity: fallow_config::Severity,
261) -> Option<GuardPolicyRule> {
262 let severity = rule.severity.unwrap_or(master_severity);
263 if severity == fallow_config::Severity::Off {
264 return None;
265 }
266
267 Some(GuardPolicyRule {
268 pack: pack.to_string(),
269 rule_id: rule.id.clone(),
270 kind: rule_kind(rule.kind).to_string(),
271 patterns: rule_patterns(rule),
272 message: rule.message.clone(),
273 severity: severity.to_string(),
274 suppress_token: format!("policy-violation:{pack}/{}", rule.id),
275 })
276}
277
278const fn rule_kind(kind: RulePackRuleKind) -> &'static str {
279 match kind {
280 RulePackRuleKind::BannedCall => "banned-call",
281 RulePackRuleKind::BannedImport => "banned-import",
282 RulePackRuleKind::BannedEffect => "banned-effect",
283 RulePackRuleKind::BannedExport => "banned-export",
284 }
285}
286
287fn rule_patterns(rule: &RulePackRule) -> Vec<String> {
288 match rule.kind {
289 RulePackRuleKind::BannedCall => rule.callees.clone(),
290 RulePackRuleKind::BannedImport => rule.specifiers.clone(),
291 RulePackRuleKind::BannedEffect => rule
292 .effects
293 .iter()
294 .map(|effect| effect.as_str().to_string())
295 .collect(),
296 RulePackRuleKind::BannedExport => rule.exports.clone(),
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use fallow_config::{
304 BoundaryCallsConfig, BoundaryConfig, BoundaryCoverageConfig, BoundaryRule, BoundaryZone,
305 EffectKind, FallowConfig, ForbiddenCallRule, ForbiddenCallee, OutputFormat, RulePackDef,
306 RulePackRule, RulePackRuleKind, RulesConfig, Severity,
307 };
308 use std::fs;
309
310 fn rule(id: &str, kind: RulePackRuleKind) -> RulePackRule {
311 RulePackRule {
312 id: id.to_string(),
313 kind,
314 callees: Vec::new(),
315 specifiers: Vec::new(),
316 effects: Vec::new(),
317 exports: Vec::new(),
318 ignore_type_only: false,
319 files: Vec::new(),
320 exclude: Vec::new(),
321 zones: Vec::new(),
322 message: None,
323 severity: None,
324 }
325 }
326
327 fn pack(rules: Vec<RulePackRule>) -> RulePackDef {
328 RulePackDef {
329 schema: None,
330 version: 1,
331 name: "team-policy".to_string(),
332 description: None,
333 rules,
334 }
335 }
336
337 fn resolve(root: &Path, configure: impl FnOnce(&mut FallowConfig)) -> ResolvedConfig {
338 let mut config = FallowConfig {
339 rules: RulesConfig {
340 policy_violation: Severity::Warn,
341 ..RulesConfig::default()
342 },
343 ..FallowConfig::default()
344 };
345 configure(&mut config);
346 config.resolve(root.to_path_buf(), OutputFormat::Json, 1, true, true, None)
347 }
348
349 #[test]
350 fn zoned_file_reports_allow_rule_and_forbidden_call() {
351 let temp = tempfile::tempdir().expect("tempdir");
352 fs::create_dir_all(temp.path().join("src/domain")).expect("create dir");
353 fs::write(temp.path().join("src/domain/user.ts"), "").expect("write file");
354 let config = resolve(temp.path(), |config| {
355 config.boundaries = BoundaryConfig {
356 zones: vec![
357 BoundaryZone {
358 name: "domain".to_string(),
359 patterns: vec!["src/domain/**".to_string()],
360 auto_discover: Vec::new(),
361 root: None,
362 },
363 BoundaryZone {
364 name: "shared".to_string(),
365 patterns: vec!["src/shared/**".to_string()],
366 auto_discover: Vec::new(),
367 root: None,
368 },
369 ],
370 rules: vec![BoundaryRule {
371 from: "domain".to_string(),
372 allow: vec!["shared".to_string()],
373 allow_type_only: vec!["ui".to_string()],
374 }],
375 calls: BoundaryCallsConfig {
376 forbidden: vec![ForbiddenCallRule {
377 from: "domain".to_string(),
378 callee: ForbiddenCallee::Single("child_process.*".to_string()),
379 }],
380 },
381 ..BoundaryConfig::default()
382 };
383 });
384
385 let report =
386 build_guard_report(&config, &["src/domain/user.ts".to_string()]).expect("report");
387 let file = &report.files[0];
388
389 assert!(file.exists);
390 assert_eq!(
391 file.zone.as_ref().map(|zone| zone.name.as_str()),
392 Some("domain")
393 );
394 assert!(!file.boundary.unrestricted);
395 assert_eq!(file.boundary.allowed_zones, vec!["domain", "shared"]);
396 assert_eq!(file.boundary.allowed_type_only_zones, vec!["ui"]);
397 assert_eq!(file.boundary.forbidden_calls, vec!["child_process.*"]);
398 assert!(file.notes.iter().any(|note| note.contains("Same-zone")));
399 }
400
401 #[test]
402 fn unzoned_file_reports_required_coverage() {
403 let temp = tempfile::tempdir().expect("tempdir");
404 let config = resolve(temp.path(), |config| {
405 config.boundaries = BoundaryConfig {
406 zones: vec![BoundaryZone {
407 name: "domain".to_string(),
408 patterns: vec!["src/domain/**".to_string()],
409 auto_discover: Vec::new(),
410 root: None,
411 }],
412 coverage: BoundaryCoverageConfig {
413 require_all_files: true,
414 allow_unmatched: vec!["src/generated/**".to_string()],
415 },
416 ..BoundaryConfig::default()
417 };
418 });
419
420 let report =
421 build_guard_report(&config, &["src/ui/button.ts".to_string()]).expect("report");
422 let file = &report.files[0];
423
424 assert!(file.zone.is_none());
425 assert!(file.boundary.unrestricted);
426 assert!(file.boundary.coverage_required);
427 assert!(
428 file.notes
429 .iter()
430 .any(|note| note.contains("outside every zone"))
431 );
432
433 let allowed =
434 build_guard_report(&config, &["src/generated/client.ts".to_string()]).expect("report");
435 assert!(!allowed.files[0].boundary.coverage_required);
436 }
437
438 #[test]
439 fn pack_rule_scope_filters_policy_rules() {
440 let temp = tempfile::tempdir().expect("tempdir");
441 let mut domain_rule = rule("pure-domain", RulePackRuleKind::BannedEffect);
442 domain_rule.effects = vec![EffectKind::Network];
443 domain_rule.files = vec!["src/domain/**".to_string()];
444 let mut excluded_rule = rule("no-generated-process", RulePackRuleKind::BannedCall);
445 excluded_rule.callees = vec!["child_process.*".to_string()];
446 excluded_rule.exclude = vec!["src/domain/**".to_string()];
447 let mut config = resolve(temp.path(), |_| {});
448 config.rule_packs = vec![pack(vec![domain_rule, excluded_rule])];
449
450 let report =
451 build_guard_report(&config, &["src/domain/user.ts".to_string()]).expect("report");
452 let rules = &report.files[0].policy_rules;
453
454 assert_eq!(rules.len(), 1);
455 assert_eq!(rules[0].rule_id, "pure-domain");
456 assert_eq!(rules[0].kind, "banned-effect");
457 assert_eq!(rules[0].patterns, vec!["network"]);
458 assert_eq!(
459 rules[0].suppress_token,
460 "policy-violation:team-policy/pure-domain"
461 );
462 assert_eq!(rules[0].severity, "warn");
463 }
464
465 #[test]
466 fn nonexistent_target_reports_exists_false() {
467 let temp = tempfile::tempdir().expect("tempdir");
468 let config = resolve(temp.path(), |_| {});
469
470 let report = build_guard_report(&config, &["src/missing.ts".to_string()]).expect("report");
471
472 assert_eq!(report.files[0].path, "src/missing.ts");
473 assert!(!report.files[0].exists);
474 }
475
476 #[test]
477 fn path_outside_root_errors() {
478 let temp = tempfile::tempdir().expect("tempdir");
479 let config = resolve(temp.path(), |_| {});
480
481 let err = build_guard_report(&config, &["../outside.ts".to_string()]).unwrap_err();
482
483 assert!(matches!(err, GuardError::OutsideRoot(_)));
484 }
485}