wowlab_tidy/languages/rust/rules/api/
filesystem_boundary.rs1#[cfg(test)]
2use googletest::prelude::*;
3use ra_ap_syntax::{
4 AstNode, NodeOrToken, SyntaxToken,
5 ast::{self},
6};
7
8use crate::{AstCtx, Example, Violation};
9
10#[rustfmt::skip]
11const EXAMPLES: &[Example] = &[
12 Example {
13 label: "direct std fs call",
14 code: "fn load() { let _ = std::fs::read(\"input\"); }",
15 pass: false,
16 },
17 Example {
18 label: "leading-colon std path",
19 code: "fn take(_: &::std::path::Path) {}",
20 pass: false,
21 },
22 Example {
23 label: "grouped fs and path imports",
24 code: "use std::{fs as native_fs, path::{Path, PathBuf}};",
25 pass: false,
26 },
27 Example {
28 label: "nested platform filesystem import",
29 code: "use std::os::{unix::fs::PermissionsExt, windows::fs as windows_fs};",
30 pass: false,
31 },
32 Example {
33 label: "tempfile import alias",
34 code: "use tempfile as scratch;",
35 pass: false,
36 },
37 Example {
38 label: "extern tempfile crate",
39 code: "extern crate tempfile as scratch;",
40 pass: false,
41 },
42 Example {
43 label: "process temp directory",
44 code: "fn scratch() { let _ = std::env::temp_dir(); }",
45 pass: false,
46 },
47 Example {
48 label: "filesystem path inside assertion macro",
49 code: "fn verify() { assert!(std::path::Path::new(\"x\").is_absolute()); }",
50 pass: false,
51 },
52 Example {
53 label: "shared filesystem API",
54 code: "use wowlab_fs::{file, path::Path};\nfn load(path: &Path) { let _ = file::read_text(path); }",
55 pass: true,
56 },
57 Example {
58 label: "unrelated path module",
59 code: "fn normalize(path: custom::path::Path) {}",
60 pass: true,
61 },
62 Example {
63 label: "forbidden text in a string",
64 code: "const HELP: &str = \"use std::fs instead\";",
65 pass: true,
66 },
67 Example {
68 label: "forbidden text in a comment",
69 code: "// std::path is implemented behind the shared boundary",
70 pass: true,
71 },
72];
73
74crate::ast_rule!(
75 filesystem_boundary,
76 "Require filesystem and native-path access to go through `wowlab-fs`.",
77 "One filesystem boundary gives the workspace consistent path vocabulary, temporary-file ownership, atomic persistence, symlink policy, and contextual errors.",
78 High,
79);
80
81fn check_filesystem_boundary(ctx: &AstCtx<'_>) -> Vec<Violation> {
82 let mut violations = Vec::new();
83 let mut trees = Vec::new();
84
85 for item in ctx.nodes::<ast::Use>() {
86 let Some(root) = item.use_tree() else {
87 continue;
88 };
89
90 trees.push(root);
91
92 while let Some(tree) = trees.pop() {
93 let segments = use_tree_segments(&tree);
94
95 if let Some(forbidden) = forbidden_path(&segments) {
96 violations.push(ctx.violation(
97 &tree,
98 format!(
99 "`{forbidden}` bypasses the shared filesystem boundary; use `wowlab-fs`"
100 ),
101 ));
102
103 continue;
104 }
105
106 if let Some(children) = tree.use_tree_list() {
107 trees.extend(children.use_trees());
108 }
109 }
110 }
111
112 for path in ctx
113 .nodes::<ast::Path>()
114 .filter(|path| !inside_use_tree(path))
115 .filter(is_outermost_path)
116 {
117 let segments = path_segments(&path);
118
119 if let Some(forbidden) = forbidden_path(&segments) {
120 violations.push(ctx.violation(
121 &path,
122 format!("`{forbidden}` bypasses the shared filesystem boundary; use `wowlab-fs`"),
123 ));
124 }
125 }
126
127 for item in ctx.nodes::<ast::ExternCrate>() {
128 if item
129 .name_ref()
130 .is_some_and(|name| name.text() == "tempfile")
131 {
132 violations.push(ctx.violation(
133 &item,
134 "`tempfile` bypasses shared temporary-file ownership; use `wowlab-fs`",
135 ));
136 }
137 }
138
139 for expression in ctx.nodes::<ast::MacroExpr>() {
140 let Some(call) = expression.macro_call() else {
141 continue;
142 };
143 let Some(forbidden) = forbidden_macro_path(&call) else {
144 continue;
145 };
146
147 violations.push(ctx.violation(
148 &expression,
149 format!(
150 "`{forbidden}` inside a macro bypasses the shared filesystem boundary; use `wowlab-fs`"
151 ),
152 ));
153 }
154
155 violations
156}
157
158fn inside_use_tree(path: &ast::Path) -> bool {
159 path.syntax()
160 .ancestors()
161 .skip(1)
162 .any(|node| ast::UseTree::cast(node).is_some())
163}
164
165fn is_outermost_path(path: &ast::Path) -> bool {
166 let nested = path
167 .syntax()
168 .ancestors()
169 .skip(1)
170 .any(|node| ast::Path::cast(node).is_some());
171
172 !nested
173}
174
175fn path_segments(path: &ast::Path) -> Vec<ast::NameRef> {
176 let mut segments = Vec::new();
177 let mut current = Some(path.clone());
178
179 while let Some(path) = current {
180 if let Some(name) = path.segment().and_then(|segment| segment.name_ref()) {
181 segments.push(name);
182 }
183
184 current = path.qualifier();
185 }
186
187 segments.reverse();
188
189 segments
190}
191
192fn use_tree_segments(tree: &ast::UseTree) -> Vec<ast::NameRef> {
193 let mut ancestors = tree
194 .syntax()
195 .ancestors()
196 .filter_map(ast::UseTree::cast)
197 .collect::<Vec<_>>();
198
199 ancestors.reverse();
200
201 let mut segments = Vec::new();
202
203 for path in ancestors.into_iter().filter_map(|tree| tree.path()) {
204 for segment in path_segments(&path) {
205 if segment.text() != "self" {
206 segments.push(segment);
207 }
208 }
209 }
210
211 segments
212}
213
214fn forbidden_path(segments: &[ast::NameRef]) -> Option<&'static str> {
215 let first = segments.first()?.text();
216
217 if first == "tempfile" {
218 return Some("tempfile");
219 }
220
221 let [first, second, tail @ ..] = segments else {
222 return None;
223 };
224
225 if first.text() != "std" {
226 return None;
227 }
228
229 match second.text().as_str() {
230 "fs" => Some("std::fs"),
231 "path" => Some("std::path"),
232 "env" if tail.first().is_some_and(|name| name.text() == "temp_dir") => {
233 Some("std::env::temp_dir")
234 }
235 "os" if matches!(
236 tail,
237 [platform, filesystem, ..]
238 if matches!(platform.text().as_str(), "unix" | "windows")
239 && filesystem.text() == "fs"
240 ) =>
241 {
242 Some("std::os::*::fs")
243 }
244 _ => None,
245 }
246}
247
248fn forbidden_macro_path(call: &ast::MacroCall) -> Option<&'static str> {
249 const PATTERNS: &[(&str, &[&str])] = &[
250 (
251 "std::os::unix::fs",
252 &["std", ":", ":", "os", ":", ":", "unix", ":", ":", "fs"],
253 ),
254 (
255 "std::os::windows::fs",
256 &["std", ":", ":", "os", ":", ":", "windows", ":", ":", "fs"],
257 ),
258 (
259 "std::env::temp_dir",
260 &["std", ":", ":", "env", ":", ":", "temp_dir"],
261 ),
262 ("std::fs", &["std", ":", ":", "fs"]),
263 ("std::path", &["std", ":", ":", "path"]),
264 ("tempfile", &["tempfile", ":", ":"]),
265 ];
266
267 let mut tokens = Vec::new();
268
269 for element in call.syntax().descendants_with_tokens() {
270 if let Some(token) = NodeOrToken::into_token(element)
271 && !token.kind().is_trivia()
272 {
273 tokens.push(token);
274 }
275 }
276
277 PATTERNS
278 .iter()
279 .find_map(|(name, pattern)| contains_token_sequence(&tokens, pattern).then_some(*name))
280}
281
282fn contains_token_sequence(tokens: &[SyntaxToken], pattern: &[&str]) -> bool {
283 tokens.windows(pattern.len()).any(|window| {
284 window
285 .iter()
286 .zip(pattern)
287 .all(|(token, expected)| token.text() == *expected)
288 })
289}
290
291crate::tidy_ast_test!(check_filesystem_boundary, {
292 crate::example_tests!(EXAMPLES, check_filesystem_boundary);
293
294 #[gtest]
295 fn grouped_imports_report_forbidden_branches_once() -> Result<()> {
296 let violations =
297 run("use std::{fs as native_fs, path::{Path, PathBuf}, collections::HashMap};");
298
299 verify_eq!(violations.len(), 2)
300 }
301
302 #[gtest]
303 fn test_modules_do_not_bypass_the_boundary() -> Result<()> {
304 let violations =
305 run("#[cfg(test)]\nmod tests {\n fn fixture() { std::fs::write(\"x\", \"y\"); }\n}");
306
307 verify_eq!(violations.len(), 1)
308 }
309});