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