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 scopes = compile_rule_scopes(config);
43 let mut reports = Vec::with_capacity(files.len());
44 for file in files {
45 reports.push(build_file_report(config, &scopes, file)?);
46 }
47 Ok(GuardReport { files: reports })
48}
49
50fn build_file_report(
51 config: &ResolvedConfig,
52 scopes: &[RuleScope<'_>],
53 input: &str,
54) -> Result<GuardFileReport, GuardError> {
55 let rel_path = normalize_target_path(config, input)?;
56 let full_path = config.root.join(&rel_path);
57 let rules = config.resolve_rules_for_path(&full_path);
58 let zone_name = config.boundaries.classify_zone(&rel_path);
59 let zone = zone_name.and_then(|name| guard_zone(&config.boundaries, name));
60 let boundary = guard_boundary(&config.boundaries, &rel_path, zone_name);
61 let notes = guard_notes(config, zone_name, boundary.coverage_required);
62
63 Ok(GuardFileReport {
64 exists: full_path.exists(),
65 boundary,
66 policy_rules: guard_policy_rules(scopes, &rel_path, zone_name, rules.policy_violation),
67 severities: GuardSeverities {
68 boundary_violation: rules.boundary_violation.to_string(),
69 policy_violation: rules.policy_violation.to_string(),
70 },
71 path: rel_path,
72 zone,
73 notes,
74 })
75}
76
77fn normalize_target_path(config: &ResolvedConfig, input: &str) -> Result<String, GuardError> {
78 let normalized = input.replace('\\', "/");
79 let path = Path::new(&normalized);
80 if looks_windows_absolute(&normalized) && !path.is_absolute() {
81 return Err(GuardError::OutsideRoot(input.to_string()));
82 }
83 let relative = if path.is_absolute() {
84 path.strip_prefix(&config.root)
85 .map_err(|_| GuardError::OutsideRoot(input.to_string()))?
86 } else {
87 path
88 };
89 normalize_relative_path(relative, input)
90}
91
92fn looks_windows_absolute(path: &str) -> bool {
93 let bytes = path.as_bytes();
94 bytes.len() >= 3 && bytes[1] == b':' && bytes[2] == b'/'
95}
96
97fn normalize_relative_path(path: &Path, original: &str) -> Result<String, GuardError> {
98 let mut parts = Vec::new();
99 for component in path.components() {
100 match component {
101 Component::CurDir => {}
102 Component::Normal(part) => parts.push(part.to_string_lossy().replace('\\', "/")),
103 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
104 return Err(GuardError::OutsideRoot(original.to_string()));
105 }
106 }
107 }
108 Ok(parts.join("/"))
109}
110
111fn guard_zone(boundaries: &ResolvedBoundaryConfig, name: &str) -> Option<GuardZone> {
112 boundaries
113 .zones
114 .iter()
115 .find(|zone| zone.name == name)
116 .map(|zone| GuardZone {
117 name: zone.name.clone(),
118 patterns: zone.patterns.clone(),
119 })
120}
121
122fn guard_boundary(
123 boundaries: &ResolvedBoundaryConfig,
124 rel_path: &str,
125 zone_name: Option<&str>,
126) -> GuardBoundary {
127 let configured = boundaries_configured(boundaries);
128 let coverage_required = zone_name.is_none()
129 && boundaries.coverage.require_all_files
130 && !boundaries.allows_unmatched(rel_path);
131
132 let Some(zone_name) = zone_name else {
133 return GuardBoundary {
134 configured,
135 unrestricted: true,
136 allowed_zones: Vec::new(),
137 allowed_type_only_zones: Vec::new(),
138 forbidden_calls: Vec::new(),
139 coverage_required,
140 };
141 };
142
143 let forbidden_calls = boundaries
144 .calls_forbidden_by_zone
145 .get(zone_name)
146 .cloned()
147 .unwrap_or_default();
148 let Some(rule) = boundaries
149 .rules
150 .iter()
151 .find(|rule| rule.from_zone == zone_name)
152 else {
153 return GuardBoundary {
154 configured,
155 unrestricted: true,
156 allowed_zones: Vec::new(),
157 allowed_type_only_zones: Vec::new(),
158 forbidden_calls,
159 coverage_required,
160 };
161 };
162
163 let mut allowed_zones = vec![zone_name.to_string()];
164 allowed_zones.extend(rule.allowed_zones.iter().cloned());
165 allowed_zones.sort();
166 allowed_zones.dedup();
167
168 GuardBoundary {
169 configured,
170 unrestricted: false,
171 allowed_zones,
172 allowed_type_only_zones: rule.allow_type_only_zones.clone(),
173 forbidden_calls,
174 coverage_required,
175 }
176}
177
178fn guard_notes(
179 config: &ResolvedConfig,
180 zone_name: Option<&str>,
181 coverage_required: bool,
182) -> Vec<String> {
183 let mut notes = Vec::new();
184 if boundaries_configured(&config.boundaries) && zone_name.is_none() {
185 notes.push(
186 "Files outside every zone are unrestricted for import and call checks.".to_string(),
187 );
188 if coverage_required {
189 notes.push(
190 "boundaries.coverage.requireAllFiles is enabled: reachable files with no zone are reported as boundary-coverage violations."
191 .to_string(),
192 );
193 }
194 }
195 if !boundaries_configured(&config.boundaries) && config.rule_packs.is_empty() {
196 notes.push("No boundary zones or rule packs are configured.".to_string());
197 }
198 if zone_name.is_some() {
199 notes.push("Same-zone imports are always allowed.".to_string());
200 }
201 notes
202}
203
204fn boundaries_configured(boundaries: &ResolvedBoundaryConfig) -> bool {
205 !boundaries.zones.is_empty() || !boundaries.logical_groups.is_empty()
206}
207
208fn guard_policy_rules(
209 scopes: &[RuleScope<'_>],
210 rel_path: &str,
211 zone: Option<&str>,
212 master_severity: fallow_config::Severity,
213) -> Vec<GuardPolicyRule> {
214 if master_severity == fallow_config::Severity::Off {
215 return Vec::new();
216 }
217
218 scopes
219 .iter()
220 .filter(|scope| {
221 compiled_scope_applies(&scope.files, &scope.exclude, &scope.zones, rel_path, zone)
222 })
223 .filter_map(|scope| guard_policy_rule(scope.pack, scope.rule, master_severity))
224 .collect()
225}
226
227struct RuleScope<'a> {
229 pack: &'a str,
230 rule: &'a RulePackRule,
231 files: Vec<globset::GlobMatcher>,
232 exclude: Vec<globset::GlobMatcher>,
233 zones: FxHashSet<String>,
234}
235
236fn compile_rule_scopes(config: &ResolvedConfig) -> Vec<RuleScope<'_>> {
237 config
238 .rule_packs
239 .iter()
240 .flat_map(|pack| {
241 pack.rules.iter().map(move |rule| RuleScope {
242 pack: pack.name.as_str(),
243 rule,
244 files: compile_scope_globs(&rule.files),
245 exclude: compile_scope_globs(&rule.exclude),
246 zones: rule.zones.iter().cloned().collect(),
247 })
248 })
249 .collect()
250}
251
252fn compile_scope_globs(patterns: &[String]) -> Vec<globset::GlobMatcher> {
253 patterns
254 .iter()
255 .filter_map(|pattern| fallow_config::compile_path_glob(pattern).ok())
256 .map(|glob| glob.compile_matcher())
257 .collect()
258}
259
260fn compiled_scope_applies(
261 files: &[globset::GlobMatcher],
262 exclude: &[globset::GlobMatcher],
263 zones: &FxHashSet<String>,
264 relative: &str,
265 zone: Option<&str>,
266) -> bool {
267 (files.is_empty() || files.iter().any(|matcher| matcher.is_match(relative)))
268 && !exclude.iter().any(|matcher| matcher.is_match(relative))
269 && (zones.is_empty() || zone.is_some_and(|zone| zones.contains(zone)))
270}
271
272fn guard_policy_rule(
273 pack: &str,
274 rule: &RulePackRule,
275 master_severity: fallow_config::Severity,
276) -> Option<GuardPolicyRule> {
277 let severity = rule.severity.unwrap_or(master_severity);
278 if severity == fallow_config::Severity::Off {
279 return None;
280 }
281
282 Some(GuardPolicyRule {
283 pack: pack.to_string(),
284 rule_id: rule.id.clone(),
285 kind: rule_kind(rule.kind).to_string(),
286 patterns: rule_patterns(rule),
287 allowed_files: rule.allowed_files.clone(),
288 proof_kinds: rule.proof_kinds.clone(),
289 message: rule.message.clone(),
290 severity: severity.to_string(),
291 suppress_token: format!("policy-violation:{pack}/{}", rule.id),
292 })
293}
294
295const fn rule_kind(kind: RulePackRuleKind) -> &'static str {
296 match kind {
297 RulePackRuleKind::BannedCall => "banned-call",
298 RulePackRuleKind::BannedImport => "banned-import",
299 RulePackRuleKind::BannedEffect => "banned-effect",
300 RulePackRuleKind::BannedExport => "banned-export",
301 RulePackRuleKind::GdpProofProducer => "gdp-proof-producer",
302 }
303}
304
305fn rule_patterns(rule: &RulePackRule) -> Vec<String> {
306 match rule.kind {
307 RulePackRuleKind::BannedCall => rule.callees.clone(),
308 RulePackRuleKind::BannedImport => rule.specifiers.clone(),
309 RulePackRuleKind::BannedEffect => rule
310 .effects
311 .iter()
312 .map(|effect| effect.as_str().to_string())
313 .collect(),
314 RulePackRuleKind::BannedExport => rule.exports.clone(),
315 RulePackRuleKind::GdpProofProducer => vec!["@gdp-ts/core.defineProof".to_string()],
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use fallow_config::{
323 BoundaryCallsConfig, BoundaryConfig, BoundaryCoverageConfig, BoundaryRule, BoundaryZone,
324 EffectKind, FallowConfig, ForbiddenCallRule, ForbiddenCallee, OutputFormat, RulePackDef,
325 RulePackRule, RulePackRuleKind, RulesConfig, Severity,
326 };
327 use std::fs;
328
329 fn rule(id: &str, kind: RulePackRuleKind) -> RulePackRule {
330 RulePackRule {
331 id: id.to_string(),
332 kind,
333 callees: Vec::new(),
334 specifiers: Vec::new(),
335 effects: Vec::new(),
336 exports: Vec::new(),
337 allowed_files: Vec::new(),
338 proof_kinds: Vec::new(),
339 ignore_type_only: false,
340 files: Vec::new(),
341 exclude: Vec::new(),
342 zones: Vec::new(),
343 message: None,
344 severity: None,
345 }
346 }
347
348 fn pack(rules: Vec<RulePackRule>) -> RulePackDef {
349 RulePackDef {
350 schema: None,
351 version: 1,
352 name: "team-policy".to_string(),
353 description: None,
354 rules,
355 }
356 }
357
358 #[test]
359 fn scope_glob_single_star_stays_within_one_segment() {
360 let matchers = compile_scope_globs(&["src/*.ts".to_string()]);
361 let matches = |path: &str| matchers.iter().any(|m| m.is_match(path));
362 assert!(matches("src/a.ts"));
363 assert!(!matches("src/nested/a.ts"));
364 }
365
366 fn resolve(root: &Path, configure: impl FnOnce(&mut FallowConfig)) -> ResolvedConfig {
367 let mut config = FallowConfig {
368 rules: RulesConfig {
369 policy_violation: Severity::Warn,
370 ..RulesConfig::default()
371 },
372 ..FallowConfig::default()
373 };
374 configure(&mut config);
375 config.resolve(root.to_path_buf(), OutputFormat::Json, 1, true, true, None)
376 }
377
378 #[test]
379 fn zoned_file_reports_allow_rule_and_forbidden_call() {
380 let temp = tempfile::tempdir().expect("tempdir");
381 fs::create_dir_all(temp.path().join("src/domain")).expect("create dir");
382 fs::write(temp.path().join("src/domain/user.ts"), "").expect("write file");
383 let config = resolve(temp.path(), |config| {
384 config.boundaries = BoundaryConfig {
385 zones: vec![
386 BoundaryZone {
387 name: "domain".to_string(),
388 patterns: vec!["src/domain/**".to_string()],
389 auto_discover: Vec::new(),
390 root: None,
391 },
392 BoundaryZone {
393 name: "shared".to_string(),
394 patterns: vec!["src/shared/**".to_string()],
395 auto_discover: Vec::new(),
396 root: None,
397 },
398 ],
399 rules: vec![BoundaryRule {
400 from: "domain".to_string(),
401 allow: vec!["shared".to_string()],
402 allow_type_only: vec!["ui".to_string()],
403 }],
404 calls: BoundaryCallsConfig {
405 forbidden: vec![ForbiddenCallRule {
406 from: "domain".to_string(),
407 callee: ForbiddenCallee::Single("child_process.*".to_string()),
408 }],
409 },
410 ..BoundaryConfig::default()
411 };
412 });
413
414 let report =
415 build_guard_report(&config, &["src/domain/user.ts".to_string()]).expect("report");
416 let file = &report.files[0];
417
418 assert!(file.exists);
419 assert_eq!(
420 file.zone.as_ref().map(|zone| zone.name.as_str()),
421 Some("domain")
422 );
423 assert!(!file.boundary.unrestricted);
424 assert_eq!(file.boundary.allowed_zones, vec!["domain", "shared"]);
425 assert_eq!(file.boundary.allowed_type_only_zones, vec!["ui"]);
426 assert_eq!(file.boundary.forbidden_calls, vec!["child_process.*"]);
427 assert!(file.notes.iter().any(|note| note.contains("Same-zone")));
428 }
429
430 fn required_coverage_config(root: &Path) -> ResolvedConfig {
431 resolve(root, |config| {
432 config.boundaries = BoundaryConfig {
433 zones: vec![BoundaryZone {
434 name: "domain".to_string(),
435 patterns: vec!["src/domain/**".to_string()],
436 auto_discover: Vec::new(),
437 root: None,
438 }],
439 coverage: BoundaryCoverageConfig {
440 require_all_files: true,
441 allow_unmatched: vec!["src/generated/**".to_string()],
442 },
443 ..BoundaryConfig::default()
444 };
445 })
446 }
447
448 #[test]
449 fn unzoned_file_reports_required_coverage() {
450 let temp = tempfile::tempdir().expect("tempdir");
451 let config = required_coverage_config(temp.path());
452
453 let report =
454 build_guard_report(&config, &["src/ui/button.ts".to_string()]).expect("report");
455 let file = &report.files[0];
456
457 assert!(file.zone.is_none());
458 assert!(file.boundary.unrestricted);
459 assert!(file.boundary.coverage_required);
460 assert!(
461 file.notes
462 .iter()
463 .any(|note| note.contains("outside every zone"))
464 );
465
466 let allowed =
467 build_guard_report(&config, &["src/generated/client.ts".to_string()]).expect("report");
468 assert!(!allowed.files[0].boundary.coverage_required);
469 }
470
471 #[test]
472 fn required_coverage_notes_state_the_requirement() {
473 let temp = tempfile::tempdir().expect("tempdir");
474 let config = required_coverage_config(temp.path());
475
476 let report =
477 build_guard_report(&config, &["src/ui/button.ts".to_string()]).expect("report");
478 let notes = &report.files[0].notes;
479
480 assert!(
481 notes
482 .iter()
483 .any(|note| note.contains("requireAllFiles") && note.contains("boundary-coverage")),
484 "unzoned file under required coverage must state the requirement: {notes:?}"
485 );
486 assert!(
487 !notes
488 .iter()
489 .any(|note| note.contains("unrestricted for boundary checks")),
490 "the unrestricted note must not claim to cover the coverage check: {notes:?}"
491 );
492
493 let allowed =
494 build_guard_report(&config, &["src/generated/client.ts".to_string()]).expect("report");
495 assert!(
496 !allowed.files[0]
497 .notes
498 .iter()
499 .any(|note| note.contains("requireAllFiles")),
500 "allowUnmatched paths must not state a coverage requirement"
501 );
502 }
503
504 #[test]
505 fn pack_rule_scope_filters_policy_rules() {
506 let temp = tempfile::tempdir().expect("tempdir");
507 let mut domain_rule = rule("pure-domain", RulePackRuleKind::BannedEffect);
508 domain_rule.effects = vec![EffectKind::Network];
509 domain_rule.files = vec!["src/domain/**".to_string()];
510 let mut excluded_rule = rule("no-generated-process", RulePackRuleKind::BannedCall);
511 excluded_rule.callees = vec!["child_process.*".to_string()];
512 excluded_rule.exclude = vec!["src/domain/**".to_string()];
513 let mut config = resolve(temp.path(), |_| {});
514 config.rule_packs = vec![pack(vec![domain_rule, excluded_rule])];
515
516 let report =
517 build_guard_report(&config, &["src/domain/user.ts".to_string()]).expect("report");
518 let rules = &report.files[0].policy_rules;
519
520 assert_eq!(rules.len(), 1);
521 assert_eq!(rules[0].rule_id, "pure-domain");
522 assert_eq!(rules[0].kind, "banned-effect");
523 assert_eq!(rules[0].patterns, vec!["network"]);
524 assert_eq!(
525 rules[0].suppress_token,
526 "policy-violation:team-policy/pure-domain"
527 );
528 assert_eq!(rules[0].severity, "warn");
529 }
530
531 #[test]
532 fn compiled_rule_scopes_match_individual_file_reports() {
533 let temp = tempfile::tempdir().expect("tempdir");
534 let mut config = resolve(temp.path(), |config| {
535 config.boundaries = BoundaryConfig {
536 zones: vec![
537 BoundaryZone {
538 name: "domain".to_string(),
539 patterns: vec!["src/domain/**".to_string()],
540 auto_discover: Vec::new(),
541 root: None,
542 },
543 BoundaryZone {
544 name: "app".to_string(),
545 patterns: vec!["src/app/**".to_string()],
546 auto_discover: Vec::new(),
547 root: None,
548 },
549 ],
550 ..BoundaryConfig::default()
551 };
552 });
553 let mut domain_rule = rule("domain-only", RulePackRuleKind::BannedImport);
554 domain_rule.files = vec!["src/domain/**".to_string()];
555 domain_rule.exclude = vec!["src/domain/generated/**".to_string()];
556 let mut app_rule = rule("app-zone", RulePackRuleKind::BannedCall);
557 app_rule.zones = vec!["app".to_string()];
558 let mut invalid_glob_rule = rule("invalid-glob", RulePackRuleKind::BannedExport);
559 invalid_glob_rule.files = vec!["[".to_string()];
560 config.rule_packs = vec![pack(vec![domain_rule, app_rule, invalid_glob_rule])];
561
562 let files = vec![
563 "src/domain/user.ts".to_string(),
564 "src/domain/generated/client.ts".to_string(),
565 "src/app/page.ts".to_string(),
566 "src/other.ts".to_string(),
567 ];
568 let batch = build_guard_report(&config, &files).expect("batch report");
569 let individual = files
570 .iter()
571 .flat_map(|file| {
572 build_guard_report(&config, std::slice::from_ref(file))
573 .expect("individual report")
574 .files
575 })
576 .collect::<Vec<_>>();
577
578 assert_eq!(
579 serde_json::to_value(&batch.files).expect("serialize batch reports"),
580 serde_json::to_value(&individual).expect("serialize individual reports")
581 );
582 let rule_ids = batch
583 .files
584 .iter()
585 .map(|file| {
586 file.policy_rules
587 .iter()
588 .map(|rule| rule.rule_id.as_str())
589 .collect::<Vec<_>>()
590 })
591 .collect::<Vec<_>>();
592 assert_eq!(
593 rule_ids,
594 vec![
595 vec!["domain-only", "invalid-glob"],
596 vec!["invalid-glob"],
597 vec!["app-zone", "invalid-glob"],
598 vec!["invalid-glob"],
599 ]
600 );
601 }
602
603 #[test]
604 fn nonexistent_target_reports_exists_false() {
605 let temp = tempfile::tempdir().expect("tempdir");
606 let config = resolve(temp.path(), |_| {});
607
608 let report = build_guard_report(&config, &["src/missing.ts".to_string()]).expect("report");
609
610 assert_eq!(report.files[0].path, "src/missing.ts");
611 assert!(!report.files[0].exists);
612 }
613
614 #[test]
615 fn path_outside_root_errors() {
616 let temp = tempfile::tempdir().expect("tempdir");
617 let config = resolve(temp.path(), |_| {});
618
619 let err = build_guard_report(&config, &["../outside.ts".to_string()]).unwrap_err();
620
621 assert!(matches!(err, GuardError::OutsideRoot(_)));
622 }
623}