Skip to main content

semifold_resolver/plugin/
file.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::fs::File;
4use std::future::Future;
5use std::io::Read;
6use std::pin::Pin;
7use std::sync::Arc;
8
9use camino::{Utf8Path, Utf8PathBuf};
10use glob::{MatchOptions, Pattern};
11
12pub(crate) const MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
13pub(crate) const MAX_OPERATION_FILE_BYTES: usize = 32 * 1024 * 1024;
14pub(crate) const MAX_OPERATION_PATHS: usize = 10_000;
15
16/// Future returned by a runtime-neutral plugin file backend.
17pub type PluginFileFuture<'a, T> =
18    Pin<Box<dyn Future<Output = Result<T, PluginFileError>> + Send + 'a>>;
19
20/// Host-controlled file capability exposed to a plugin operation.
21pub trait PluginFileClient: fmt::Debug + Send + Sync + 'static {
22    fn list_files(&self, pattern: &str) -> PluginFileFuture<'_, Vec<String>>;
23
24    fn read_text(&self, path: &str) -> PluginFileFuture<'_, String>;
25}
26
27/// Default file capability. Plugins cannot inspect the project unless the host injects a client.
28#[derive(Clone, Copy, Debug, Default)]
29pub struct DenyPluginFileClient;
30
31impl PluginFileClient for DenyPluginFileClient {
32    fn list_files(&self, _pattern: &str) -> PluginFileFuture<'_, Vec<String>> {
33        Box::pin(async { Err(PluginFileError::NotConfigured) })
34    }
35
36    fn read_text(&self, _path: &str) -> PluginFileFuture<'_, String> {
37        Box::pin(async { Err(PluginFileError::NotConfigured) })
38    }
39}
40
41/// Project-scoped implementation of the plugin file capability.
42///
43/// The declared patterns are an allowlist: `list_files` only accepts an exact pattern from that
44/// set, while `read_text` only accepts a normalized relative path matched by at least one pattern.
45#[derive(Clone, Debug)]
46pub struct ScopedPluginFileClient {
47    root: Utf8PathBuf,
48    patterns: Arc<BTreeMap<String, AllowedPattern>>,
49}
50
51#[derive(Clone, Debug)]
52struct AllowedPattern {
53    compiled: Pattern,
54    segments: Vec<AllowedPatternSegment>,
55}
56
57#[derive(Clone, Debug)]
58enum AllowedPatternSegment {
59    Recursive,
60    Pattern(Pattern),
61}
62
63impl AllowedPattern {
64    fn new(pattern: &str) -> Result<Self, PluginFileError> {
65        let compiled = Pattern::new(pattern).map_err(|source| PluginFileError::InvalidPattern {
66            pattern: pattern.to_owned(),
67            reason: source.to_string(),
68        })?;
69        let segments = pattern
70            .split('/')
71            .map(|segment| {
72                if segment == "**" {
73                    Ok(AllowedPatternSegment::Recursive)
74                } else {
75                    Pattern::new(segment)
76                        .map(AllowedPatternSegment::Pattern)
77                        .map_err(|source| PluginFileError::InvalidPattern {
78                            pattern: pattern.to_owned(),
79                            reason: source.to_string(),
80                        })
81                }
82            })
83            .collect::<Result<Vec<_>, _>>()?;
84        Ok(Self { compiled, segments })
85    }
86
87    fn matches(&self, path: &str) -> bool {
88        self.compiled.matches_with(path, match_options())
89    }
90
91    fn can_match_below(&self, directory: &Utf8Path) -> bool {
92        let mut states = BTreeSet::new();
93        self.add_recursive_closure(0, &mut states);
94        for component in directory
95            .as_str()
96            .split('/')
97            .filter(|part| !part.is_empty())
98        {
99            let mut next = BTreeSet::new();
100            for state in states {
101                match self.segments.get(state) {
102                    Some(AllowedPatternSegment::Recursive) if !component.starts_with('.') => {
103                        self.add_recursive_closure(state, &mut next);
104                    }
105                    Some(AllowedPatternSegment::Pattern(pattern))
106                        if pattern.matches_with(component, match_options()) =>
107                    {
108                        self.add_recursive_closure(state + 1, &mut next);
109                    }
110                    _ => {}
111                }
112            }
113            states = next;
114            if states.is_empty() {
115                return false;
116            }
117        }
118        states.into_iter().any(|state| state < self.segments.len())
119    }
120
121    fn add_recursive_closure(&self, mut state: usize, states: &mut BTreeSet<usize>) {
122        states.insert(state);
123        while matches!(
124            self.segments.get(state),
125            Some(AllowedPatternSegment::Recursive)
126        ) {
127            state += 1;
128            states.insert(state);
129        }
130    }
131}
132
133impl ScopedPluginFileClient {
134    pub fn new(
135        root: impl Into<Utf8PathBuf>,
136        read_patterns: impl IntoIterator<Item = String>,
137    ) -> Result<Self, PluginFileError> {
138        let configured_root = root.into();
139        let canonical_root = std::fs::canonicalize(&configured_root).map_err(|source| {
140            PluginFileError::ResolveRoot {
141                root: configured_root.clone(),
142                source,
143            }
144        })?;
145        let root = Utf8PathBuf::from_path_buf(canonical_root).map_err(|path| {
146            PluginFileError::NonUtf8Root {
147                root: path.display().to_string(),
148            }
149        })?;
150        if !root.is_dir() {
151            return Err(PluginFileError::RootNotDirectory { root });
152        }
153
154        let mut patterns = BTreeMap::new();
155        for pattern in read_patterns {
156            validate_protocol_path(&pattern, PathKind::Pattern)?;
157            let compiled = AllowedPattern::new(&pattern)?;
158            patterns.insert(pattern, compiled);
159        }
160
161        Ok(Self {
162            root,
163            patterns: Arc::new(patterns),
164        })
165    }
166
167    fn list_files_sync(&self, requested_pattern: &str) -> Result<Vec<String>, PluginFileError> {
168        validate_protocol_path(requested_pattern, PathKind::Pattern)?;
169        let pattern = self.patterns.get(requested_pattern).ok_or_else(|| {
170            PluginFileError::PatternNotAllowed {
171                pattern: requested_pattern.to_owned(),
172            }
173        })?;
174        let mut matches = BTreeSet::new();
175        self.walk_directory(
176            &self.root,
177            Utf8Path::new(""),
178            pattern,
179            &mut BTreeSet::new(),
180            &mut matches,
181        )?;
182        Ok(matches.into_iter().collect())
183    }
184
185    fn walk_directory(
186        &self,
187        directory: &Utf8Path,
188        logical_directory: &Utf8Path,
189        pattern: &AllowedPattern,
190        ancestors: &mut BTreeSet<Utf8PathBuf>,
191        matches: &mut BTreeSet<String>,
192    ) -> Result<(), PluginFileError> {
193        let canonical = self.resolve_inside_root(directory, logical_directory.as_str())?;
194        if !ancestors.insert(canonical.clone()) {
195            return Err(PluginFileError::DirectoryCycle {
196                path: logical_directory.to_owned(),
197            });
198        }
199        let entries =
200            std::fs::read_dir(&canonical).map_err(|source| PluginFileError::ReadDirectory {
201                path: canonical.clone(),
202                source,
203            })?;
204        for entry in entries {
205            let entry = entry.map_err(|source| PluginFileError::ReadDirectory {
206                path: canonical.clone(),
207                source,
208            })?;
209            let path = Utf8PathBuf::from_path_buf(entry.path()).map_err(|path| {
210                PluginFileError::NonUtf8Path {
211                    path: path.display().to_string(),
212                }
213            })?;
214            let name =
215                entry
216                    .file_name()
217                    .into_string()
218                    .map_err(|name| PluginFileError::NonUtf8Path {
219                        path: name.to_string_lossy().into_owned(),
220                    })?;
221            let logical_path = logical_directory.join(name);
222            let file_type = entry
223                .file_type()
224                .map_err(|source| PluginFileError::InspectPath {
225                    path: path.clone(),
226                    source,
227                })?;
228
229            if file_type.is_dir() {
230                if pattern.can_match_below(&logical_path) {
231                    self.walk_directory(&path, &logical_path, pattern, ancestors, matches)?;
232                }
233                continue;
234            }
235
236            if file_type.is_symlink() {
237                self.collect_symlink(&path, &logical_path, pattern, ancestors, matches)?;
238                continue;
239            }
240
241            if file_type.is_file() && pattern.matches(logical_path.as_str()) {
242                insert_match(matches, logical_path.as_str())?;
243            }
244        }
245        ancestors.remove(&canonical);
246        Ok(())
247    }
248
249    fn collect_symlink(
250        &self,
251        path: &Utf8Path,
252        logical_path: &Utf8Path,
253        pattern: &AllowedPattern,
254        ancestors: &mut BTreeSet<Utf8PathBuf>,
255        matches: &mut BTreeSet<String>,
256    ) -> Result<(), PluginFileError> {
257        let matches_file = pattern.matches(logical_path.as_str());
258        let matches_below = pattern.can_match_below(logical_path);
259        if !matches_file && !matches_below {
260            return Ok(());
261        }
262        let canonical = self.resolve_inside_root(path, logical_path.as_str())?;
263        if canonical.is_dir() && matches_below {
264            self.walk_directory(&canonical, logical_path, pattern, ancestors, matches)?;
265        } else if canonical.is_file() && matches_file {
266            insert_match(matches, logical_path.as_str())?;
267        }
268        Ok(())
269    }
270
271    fn read_text_sync(&self, requested_path: &str) -> Result<String, PluginFileError> {
272        validate_protocol_path(requested_path, PathKind::File)?;
273        if !self
274            .patterns
275            .values()
276            .any(|pattern| pattern.matches(requested_path))
277        {
278            return Err(PluginFileError::PathNotAllowed {
279                path: requested_path.to_owned(),
280            });
281        }
282
283        let path = self.root.join(requested_path);
284        let canonical = self.resolve_inside_root(&path, requested_path)?;
285        let metadata = canonical
286            .metadata()
287            .map_err(|source| PluginFileError::InspectPath {
288                path: canonical.clone(),
289                source,
290            })?;
291        if !metadata.is_file() {
292            return Err(PluginFileError::NotAFile {
293                path: requested_path.to_owned(),
294            });
295        }
296        let actual = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
297        if actual > MAX_FILE_BYTES {
298            return Err(PluginFileError::FileTooLarge {
299                path: requested_path.to_owned(),
300                actual,
301                maximum: MAX_FILE_BYTES,
302            });
303        }
304
305        let file = File::open(&canonical).map_err(|source| PluginFileError::ReadFile {
306            path: requested_path.to_owned(),
307            source,
308        })?;
309        let maximum =
310            u64::try_from(MAX_FILE_BYTES).map_err(|source| PluginFileError::InternalLimit {
311                reason: source.to_string(),
312            })?;
313        let mut bytes = Vec::with_capacity(actual);
314        file.take(maximum.saturating_add(1))
315            .read_to_end(&mut bytes)
316            .map_err(|source| PluginFileError::ReadFile {
317                path: requested_path.to_owned(),
318                source,
319            })?;
320        if bytes.len() > MAX_FILE_BYTES {
321            return Err(PluginFileError::FileTooLarge {
322                path: requested_path.to_owned(),
323                actual: bytes.len(),
324                maximum: MAX_FILE_BYTES,
325            });
326        }
327        String::from_utf8(bytes).map_err(|source| PluginFileError::InvalidUtf8 {
328            path: requested_path.to_owned(),
329            source,
330        })
331    }
332
333    fn resolve_inside_root(
334        &self,
335        path: &Utf8Path,
336        relative: &str,
337    ) -> Result<Utf8PathBuf, PluginFileError> {
338        let canonical =
339            std::fs::canonicalize(path).map_err(|source| PluginFileError::ResolvePath {
340                path: relative.to_owned(),
341                source,
342            })?;
343        let canonical =
344            Utf8PathBuf::from_path_buf(canonical).map_err(|path| PluginFileError::NonUtf8Path {
345                path: path.display().to_string(),
346            })?;
347        if !canonical.starts_with(&self.root) {
348            return Err(PluginFileError::OutsideProjectRoot {
349                path: relative.to_owned(),
350            });
351        }
352        Ok(canonical)
353    }
354}
355
356impl PluginFileClient for ScopedPluginFileClient {
357    fn list_files(&self, pattern: &str) -> PluginFileFuture<'_, Vec<String>> {
358        let pattern = pattern.to_owned();
359        Box::pin(async move { self.list_files_sync(&pattern) })
360    }
361
362    fn read_text(&self, path: &str) -> PluginFileFuture<'_, String> {
363        let path = path.to_owned();
364        Box::pin(async move { self.read_text_sync(&path) })
365    }
366}
367
368fn insert_match(matches: &mut BTreeSet<String>, path: &str) -> Result<(), PluginFileError> {
369    matches.insert(path.to_owned());
370    if matches.len() > MAX_OPERATION_PATHS {
371        return Err(PluginFileError::TooManyPaths {
372            actual: matches.len(),
373            maximum: MAX_OPERATION_PATHS,
374        });
375    }
376    Ok(())
377}
378
379#[derive(Clone, Copy)]
380enum PathKind {
381    File,
382    Pattern,
383}
384
385pub(crate) fn validate_file_path(value: &str) -> Result<(), PluginFileError> {
386    validate_protocol_path(value, PathKind::File)
387}
388
389pub(crate) fn validate_pattern_path(value: &str) -> Result<(), PluginFileError> {
390    validate_protocol_path(value, PathKind::Pattern)
391}
392
393pub(crate) fn matches_pattern(pattern: &str, path: &str) -> Result<bool, PluginFileError> {
394    let pattern = Pattern::new(pattern).map_err(|source| PluginFileError::InvalidPattern {
395        pattern: pattern.to_owned(),
396        reason: source.to_string(),
397    })?;
398    Ok(pattern.matches_with(path, match_options()))
399}
400
401fn validate_protocol_path(value: &str, kind: PathKind) -> Result<(), PluginFileError> {
402    let invalid = value.is_empty()
403        || value.contains('\\')
404        || value.starts_with('/')
405        || value.ends_with('/')
406        || value.split('/').any(|segment| {
407            segment.is_empty()
408                || segment == "."
409                || segment == ".."
410                || is_windows_drive_segment(segment)
411        });
412    if invalid {
413        return match kind {
414            PathKind::File => Err(PluginFileError::InvalidPath {
415                path: value.to_owned(),
416            }),
417            PathKind::Pattern => Err(PluginFileError::InvalidPattern {
418                pattern: value.to_owned(),
419                reason: "patterns must be normalized project-relative UTF-8 paths".to_owned(),
420            }),
421        };
422    }
423    Ok(())
424}
425
426fn is_windows_drive_segment(segment: &str) -> bool {
427    let bytes = segment.as_bytes();
428    bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
429}
430
431const fn match_options() -> MatchOptions {
432    MatchOptions {
433        case_sensitive: true,
434        require_literal_separator: true,
435        require_literal_leading_dot: true,
436    }
437}
438
439#[derive(Debug, thiserror::Error)]
440pub enum PluginFileError {
441    #[error("plugin file access is not configured")]
442    NotConfigured,
443    #[error("failed to resolve plugin project root `{root}`: {source}")]
444    ResolveRoot {
445        root: Utf8PathBuf,
446        #[source]
447        source: std::io::Error,
448    },
449    #[error("plugin project root is not UTF-8: `{root}`")]
450    NonUtf8Root { root: String },
451    #[error("plugin project root is not a directory: `{root}`")]
452    RootNotDirectory { root: Utf8PathBuf },
453    #[error("invalid plugin read pattern `{pattern}`: {reason}")]
454    InvalidPattern { pattern: String, reason: String },
455    #[error("plugin read pattern is not allowed: `{pattern}`")]
456    PatternNotAllowed { pattern: String },
457    #[error("invalid plugin file path: `{path}`")]
458    InvalidPath { path: String },
459    #[error("plugin file path is not allowed: `{path}`")]
460    PathNotAllowed { path: String },
461    #[error("plugin file path is outside the project root: `{path}`")]
462    OutsideProjectRoot { path: String },
463    #[error("plugin file path is not UTF-8: `{path}`")]
464    NonUtf8Path { path: String },
465    #[error("failed to read plugin directory `{path}`: {source}")]
466    ReadDirectory {
467        path: Utf8PathBuf,
468        #[source]
469        source: std::io::Error,
470    },
471    #[error("failed to inspect plugin file path `{path}`: {source}")]
472    InspectPath {
473        path: Utf8PathBuf,
474        #[source]
475        source: std::io::Error,
476    },
477    #[error("failed to resolve plugin file path `{path}`: {source}")]
478    ResolvePath {
479        path: String,
480        #[source]
481        source: std::io::Error,
482    },
483    #[error("plugin file traversal encountered a directory cycle at `{path}`")]
484    DirectoryCycle { path: Utf8PathBuf },
485    #[error("plugin file path is not a regular file: `{path}`")]
486    NotAFile { path: String },
487    #[error("plugin file `{path}` contains {actual} bytes; maximum is {maximum}")]
488    FileTooLarge {
489        path: String,
490        actual: usize,
491        maximum: usize,
492    },
493    #[error("failed to read plugin file `{path}`: {source}")]
494    ReadFile {
495        path: String,
496        #[source]
497        source: std::io::Error,
498    },
499    #[error("plugin file `{path}` is not valid UTF-8: {source}")]
500    InvalidUtf8 {
501        path: String,
502        #[source]
503        source: std::string::FromUtf8Error,
504    },
505    #[error("plugin file listing returned {actual} paths; maximum is {maximum}")]
506    TooManyPaths { actual: usize, maximum: usize },
507    #[error("plugin file capability `{method}` requires a string argument")]
508    InvalidArgument { method: &'static str },
509    #[error("plugin file capability host is unavailable")]
510    HostUnavailable,
511    #[error("plugin file capability budget state is unavailable")]
512    BudgetStateUnavailable,
513    #[error("plugin file capability returned path `{path}` that does not match `{pattern}`")]
514    ReturnedPathDoesNotMatch { path: String, pattern: String },
515    #[error("plugin file reads returned {actual} bytes in this operation; maximum is {maximum}")]
516    OperationBytesExceeded { actual: usize, maximum: usize },
517    #[error("invalid internal plugin file limit: {reason}")]
518    InternalLimit { reason: String },
519}
520
521#[cfg(test)]
522mod tests {
523    use std::fs;
524    use std::time::{SystemTime, UNIX_EPOCH};
525
526    use super::*;
527
528    fn fixture_root(test: &str) -> Utf8PathBuf {
529        let nonce = SystemTime::now()
530            .duration_since(UNIX_EPOCH)
531            .unwrap()
532            .as_nanos();
533        let root = std::env::temp_dir().join(format!(
534            "semifold-plugin-file-{}-{test}-{nonce}",
535            std::process::id()
536        ));
537        fs::create_dir_all(&root).unwrap();
538        Utf8PathBuf::from_path_buf(root).unwrap()
539    }
540
541    #[test]
542    fn lists_declared_patterns_in_sorted_order_and_reads_matching_text() {
543        let root = fixture_root("list-and-read");
544        fs::create_dir_all(root.join("packages/zeta")).unwrap();
545        fs::create_dir_all(root.join("packages/alpha")).unwrap();
546        fs::write(root.join("packages/zeta/package.json"), "zeta").unwrap();
547        fs::write(root.join("packages/alpha/package.json"), "alpha").unwrap();
548        fs::write(root.join("packages/alpha/ignored.toml"), "ignored").unwrap();
549        let client =
550            ScopedPluginFileClient::new(root.clone(), ["packages/**/package.json".to_owned()])
551                .unwrap();
552
553        assert_eq!(
554            client.list_files_sync("packages/**/package.json").unwrap(),
555            vec![
556                "packages/alpha/package.json".to_owned(),
557                "packages/zeta/package.json".to_owned()
558            ]
559        );
560        assert_eq!(
561            client
562                .read_text_sync("packages/alpha/package.json")
563                .unwrap(),
564            "alpha"
565        );
566        fs::remove_dir_all(root).unwrap();
567    }
568
569    #[test]
570    fn rejects_undeclared_patterns_and_non_matching_paths() {
571        let root = fixture_root("authorization");
572        fs::write(root.join("package.json"), "{}").unwrap();
573        fs::write(root.join("secret.txt"), "secret").unwrap();
574        let client =
575            ScopedPluginFileClient::new(root.clone(), ["package.json".to_owned()]).unwrap();
576
577        assert!(matches!(
578            client.list_files_sync("*.json"),
579            Err(PluginFileError::PatternNotAllowed { .. })
580        ));
581        assert!(matches!(
582            client.read_text_sync("secret.txt"),
583            Err(PluginFileError::PathNotAllowed { .. })
584        ));
585        assert!(matches!(
586            client.read_text_sync("../secret.txt"),
587            Err(PluginFileError::InvalidPath { .. })
588        ));
589        fs::remove_dir_all(root).unwrap();
590    }
591
592    #[test]
593    fn rejects_invalid_utf8_file_content() {
594        let root = fixture_root("invalid-utf8");
595        fs::write(root.join("invalid.txt"), [0xff, 0xfe]).unwrap();
596        let client = ScopedPluginFileClient::new(root.clone(), ["*.txt".to_owned()]).unwrap();
597
598        assert!(matches!(
599            client.read_text_sync("invalid.txt"),
600            Err(PluginFileError::InvalidUtf8 { .. })
601        ));
602        fs::remove_dir_all(root).unwrap();
603    }
604
605    #[test]
606    fn rejects_files_larger_than_the_per_file_budget() {
607        let root = fixture_root("file-budget");
608        fs::write(root.join("large.txt"), vec![b'x'; MAX_FILE_BYTES + 1]).unwrap();
609        let client = ScopedPluginFileClient::new(root.clone(), ["*.txt".to_owned()]).unwrap();
610
611        assert!(matches!(
612            client.read_text_sync("large.txt"),
613            Err(PluginFileError::FileTooLarge {
614                actual,
615                maximum: MAX_FILE_BYTES,
616                ..
617            }) if actual == MAX_FILE_BYTES + 1
618        ));
619        fs::remove_dir_all(root).unwrap();
620    }
621
622    #[cfg(unix)]
623    #[test]
624    fn rejects_symlinks_that_escape_the_project_root() {
625        use std::os::unix::fs::symlink;
626
627        let root = fixture_root("symlink-root");
628        let outside = fixture_root("symlink-outside");
629        fs::write(outside.join("secret.txt"), "secret").unwrap();
630        symlink(outside.join("secret.txt"), root.join("secret.txt")).unwrap();
631        let client = ScopedPluginFileClient::new(root.clone(), ["*.txt".to_owned()]).unwrap();
632
633        assert!(matches!(
634            client.list_files_sync("*.txt"),
635            Err(PluginFileError::OutsideProjectRoot { .. })
636        ));
637        assert!(matches!(
638            client.read_text_sync("secret.txt"),
639            Err(PluginFileError::OutsideProjectRoot { .. })
640        ));
641        fs::remove_dir_all(root).unwrap();
642        fs::remove_dir_all(outside).unwrap();
643    }
644
645    #[cfg(unix)]
646    #[test]
647    fn traverses_in_root_directory_symlinks_and_rejects_external_targets() {
648        use std::os::unix::fs::symlink;
649
650        let root = fixture_root("directory-symlinks");
651        let outside = fixture_root("external-directory");
652        fs::create_dir_all(root.join("actual")).unwrap();
653        fs::write(root.join("actual/package.json"), "{}").unwrap();
654        fs::write(outside.join("package.json"), "{}").unwrap();
655        symlink(root.join("actual"), root.join("linked")).unwrap();
656        symlink(&outside, root.join("external")).unwrap();
657        let client = ScopedPluginFileClient::new(
658            root.clone(),
659            [
660                "linked/**/*.json".to_owned(),
661                "external/**/*.json".to_owned(),
662            ],
663        )
664        .unwrap();
665
666        assert_eq!(
667            client.list_files_sync("linked/**/*.json").unwrap(),
668            vec!["linked/package.json".to_owned()]
669        );
670        assert!(matches!(
671            client.list_files_sync("external/**/*.json"),
672            Err(PluginFileError::OutsideProjectRoot { .. })
673        ));
674        fs::remove_dir_all(root).unwrap();
675        fs::remove_dir_all(outside).unwrap();
676    }
677
678    #[cfg(unix)]
679    #[test]
680    fn rejects_directory_symlink_cycles() {
681        use std::os::unix::fs::symlink;
682
683        let root = fixture_root("symlink-cycle");
684        fs::create_dir_all(root.join("data")).unwrap();
685        symlink(&root, root.join("data/back")).unwrap();
686        let client =
687            ScopedPluginFileClient::new(root.clone(), ["data/**/*.json".to_owned()]).unwrap();
688
689        assert!(matches!(
690            client.list_files_sync("data/**/*.json"),
691            Err(PluginFileError::DirectoryCycle { .. })
692        ));
693        fs::remove_dir_all(root).unwrap();
694    }
695}