Skip to main content

wowlab_tidy/languages/rust/rules/api/
filesystem_boundary.rs

1#[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});