1use anyhow::{Result, bail};
2use futures::try_join;
3use rustc_hash::FxHashMap;
4use turbo_rcstr::RcStr;
5use turbo_tasks::{Completion, ResolvedVc, TryJoinIterExt, Vc, turbobail};
6
7use crate::{
8 DirectoryContent, DirectoryEntry, FileSystem, FileSystemPath, LinkContent, LinkType, glob::Glob,
9};
10
11#[turbo_tasks::value]
12#[derive(Default, Debug)]
13pub struct ReadGlobResult {
14 pub results: FxHashMap<RcStr, DirectoryEntry>,
15 pub inner: FxHashMap<RcStr, ResolvedVc<ReadGlobResult>>,
16}
17
18#[turbo_tasks::function(fs)]
23pub async fn read_glob(directory: FileSystemPath, glob: Vc<Glob>) -> Result<Vc<ReadGlobResult>> {
24 read_glob_internal("", directory, glob).await
25}
26
27#[turbo_tasks::function(fs)]
28async fn read_glob_inner(
29 prefix: RcStr,
30 directory: FileSystemPath,
31 glob: Vc<Glob>,
32) -> Result<Vc<ReadGlobResult>> {
33 read_glob_internal(&prefix, directory, glob).await
34}
35
36async fn read_glob_internal(
38 prefix: &str,
39 directory: FileSystemPath,
40 glob: Vc<Glob>,
41) -> Result<Vc<ReadGlobResult>> {
42 let dir = directory.read_dir().await?;
43 let mut result = ReadGlobResult::default();
44 let glob_value = glob.await?;
45 let handle_file = |result: &mut ReadGlobResult,
46 entry_path: &RcStr,
47 segment: &RcStr,
48 entry: &DirectoryEntry| {
49 if glob_value.matches(entry_path) {
50 result.results.insert(segment.clone(), entry.clone());
51 }
52 };
53 let handle_dir = async |result: &mut ReadGlobResult,
54 entry_path: RcStr,
55 segment: &RcStr,
56 path: &FileSystemPath| {
57 if glob_value.can_match_in_directory(&entry_path) {
58 result.inner.insert(
59 segment.clone(),
60 read_glob_inner(entry_path, path.clone(), glob)
61 .to_resolved()
62 .await?,
63 );
64 }
65 anyhow::Ok(())
66 };
67
68 match &*dir {
69 DirectoryContent::Entries(entries) => {
70 for (segment, entry) in entries.iter() {
71 let entry_path: RcStr = if prefix.is_empty() {
72 segment.clone()
73 } else {
74 format!("{prefix}/{segment}").into()
75 };
76
77 match entry {
78 DirectoryEntry::File(_) => {
79 handle_file(&mut result, &entry_path, segment, entry);
80 }
81 DirectoryEntry::Directory(path) => {
82 handle_file(&mut result, &entry_path, segment, entry);
84 handle_dir(&mut result, entry_path, segment, path).await?;
86 }
87 DirectoryEntry::Symlink(path) => {
88 if let LinkContent::Link { link_type, .. } = &*path.read_link().await? {
89 if link_type.contains(LinkType::DIRECTORY) {
90 resolve_symlink_safely(entry.clone()).await?;
92
93 handle_file(&mut result, &entry_path, segment, entry);
95 handle_dir(&mut result, entry_path, segment, path).await?;
97 } else {
98 handle_file(&mut result, &entry_path, segment, entry);
99 }
100 }
101 }
102 DirectoryEntry::Other(_) | DirectoryEntry::Error(_) => continue,
103 }
104 }
105 }
106 DirectoryContent::NotFound => {}
107 }
108 Ok(ReadGlobResult::cell(result))
109}
110
111async fn resolve_symlink_safely(entry: DirectoryEntry) -> Result<DirectoryEntry> {
113 let resolved_entry = entry.clone().resolve_symlink().await?;
114 if resolved_entry != entry && matches!(&resolved_entry, DirectoryEntry::Directory(_)) {
115 let source_path = entry.path().unwrap();
124 if source_path.is_inside_or_equal(&resolved_entry.clone().path().unwrap()) {
125 bail!("'{source_path}' is a symlink causes that causes an infinite loop!",)
126 }
127 }
128 Ok(resolved_entry)
129}
130
131#[turbo_tasks::function(fs)]
137pub async fn track_glob(
138 directory: FileSystemPath,
139 glob: Vc<Glob>,
140 include_dot_files: bool,
141) -> Result<Vc<Completion>> {
142 track_glob_internal("", directory, glob, include_dot_files).await
143}
144
145#[turbo_tasks::function(fs)]
146async fn track_glob_inner(
147 prefix: RcStr,
148 directory: FileSystemPath,
149 glob: Vc<Glob>,
150 include_dot_files: bool,
151) -> Result<Vc<Completion>> {
152 track_glob_internal(&prefix, directory, glob, include_dot_files).await
153}
154
155async fn track_glob_internal(
156 prefix: &str,
157 directory: FileSystemPath,
158 glob: Vc<Glob>,
159 include_dot_files: bool,
160) -> Result<Vc<Completion>> {
161 let dir = directory.read_dir().await?;
162 let glob_value = glob.await?;
163 let fs = directory.fs().to_resolved().await?;
164 let mut reads = Vec::new();
165 let mut completions = Vec::new();
166 let mut types = Vec::new();
167 match &*dir {
168 DirectoryContent::Entries(entries) => {
169 for (segment, entry) in entries.iter() {
170 if !include_dot_files && segment.starts_with('.') {
171 continue;
172 }
173 let entry_path = if prefix.is_empty() {
176 segment.clone()
177 } else {
178 format!("{prefix}/{segment}").into()
179 };
180
181 match resolve_symlink_safely(entry.clone()).await? {
182 DirectoryEntry::Directory(path) => {
183 if glob_value.can_match_in_directory(&entry_path) {
184 completions.push(track_glob_inner(
185 entry_path,
186 path.clone(),
187 glob,
188 include_dot_files,
189 ));
190 }
191 }
192 DirectoryEntry::File(path) => {
193 if glob_value.matches(&entry_path) {
194 reads.push(fs.read(path.clone()))
195 }
196 }
197 DirectoryEntry::Symlink(symlink_path) => turbobail!(
198 "resolve_symlink_safely() should have resolved all symlinks or returned \
199 an error, but found unresolved symlink at path: '{entry_path}'. Found \
200 path: '{symlink_path}'. Please report this as a bug.",
201 ),
202 DirectoryEntry::Other(path) => {
203 if glob_value.matches(&entry_path) {
204 types.push(path.get_type())
205 }
206 }
207 DirectoryEntry::Error(_) => {}
212 }
213 }
214 }
215 DirectoryContent::NotFound => {}
216 }
217 try_join!(
218 reads.iter().try_join(),
219 types.iter().try_join(),
220 completions.iter().try_join()
221 )?;
222 Ok(Completion::new())
223}
224
225#[cfg(test)]
226pub mod tests {
227
228 use std::{
229 collections::HashMap,
230 fs::{File, create_dir},
231 io::prelude::*,
232 };
233
234 use turbo_rcstr::{RcStr, rcstr};
235 use turbo_tasks::{Completion, Effects, OperationVc, ReadRef, Vc, take_effects};
236 use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
237
238 use crate::{
239 DirectoryEntry, DiskFileSystem, FileContent, FileSystem, FileSystemPath,
240 glob::{Glob, GlobOptions},
241 };
242
243 fn symlink<P: AsRef<std::path::Path>, Q: AsRef<std::path::Path>>(
244 target: Q,
245 path: P,
246 ) -> std::io::Result<()> {
247 assert!(target.as_ref().is_absolute());
248 let _ = std::fs::remove_dir(&path);
249 let _ = std::fs::remove_file(&path);
250
251 #[cfg(unix)]
252 {
253 std::os::unix::fs::symlink(target, path)
254 }
255 #[cfg(windows)]
256 {
257 let metadata = std::fs::metadata(&target).ok();
258 if metadata.is_none_or(|m| m.is_file()) {
259 std::os::windows::fs::symlink_file(target, path)
260 } else {
261 std::os::windows::fs::junction_point(target, path)
262 }
263 }
264 }
265
266 #[turbo_tasks::function(operation)]
267 async fn assert_read_glob_basic_operation(path: RcStr) -> anyhow::Result<()> {
268 let fs = DiskFileSystem::new(rcstr!("temp"), Vc::cell(path));
269 let root = fs.root().await?;
270 let read_dir = root
271 .read_glob(Glob::new(rcstr!("**"), GlobOptions::default()))
272 .await
273 .unwrap();
274 assert_eq!(read_dir.results.len(), 2);
275 assert_eq!(
276 read_dir.results.get("foo"),
277 Some(&DirectoryEntry::File(fs.root().await?.join("foo")?))
278 );
279 assert_eq!(
280 read_dir.results.get("sub"),
281 Some(&DirectoryEntry::Directory(fs.root().await?.join("sub")?))
282 );
283 assert_eq!(read_dir.inner.len(), 1);
284 let inner = &*read_dir.inner.get("sub").unwrap().await?;
285 assert_eq!(inner.results.len(), 1);
286 assert_eq!(
287 inner.results.get("bar"),
288 Some(&DirectoryEntry::File(fs.root().await?.join("sub/bar")?))
289 );
290 assert_eq!(inner.inner.len(), 0);
291
292 let read_dir = root
293 .read_glob(Glob::new(rcstr!("**/bar"), GlobOptions::default()))
294 .await
295 .unwrap();
296 assert_eq!(read_dir.results.len(), 0);
297 assert_eq!(read_dir.inner.len(), 1);
298 let inner = &*read_dir.inner.get("sub").unwrap().await?;
299 assert_eq!(inner.results.len(), 1);
300 assert_eq!(
301 inner.results.get("bar"),
302 Some(&DirectoryEntry::File(fs.root().await?.join("sub/bar")?))
303 );
304 assert_eq!(inner.inner.len(), 0);
305
306 Ok(())
307 }
308
309 #[turbo_tasks::function(operation)]
310 async fn assert_read_glob_symlinks_operation(path: RcStr) -> anyhow::Result<()> {
311 let fs = DiskFileSystem::new(rcstr!("temp"), Vc::cell(path));
312 let root = fs.root().await?;
313 let read_dir = root
315 .read_glob(Glob::new(rcstr!("sub/*.js"), GlobOptions::default()))
316 .await
317 .unwrap();
318 assert_eq!(read_dir.results.len(), 0);
319 let inner = &*read_dir.inner.get("sub").unwrap().await?;
320 assert_eq!(
321 inner.results,
322 HashMap::from_iter([
323 (
324 "link-foo.js".into(),
325 DirectoryEntry::Symlink(root.join("sub/link-foo.js")?),
326 ),
327 (
328 "link-root.js".into(),
329 DirectoryEntry::Symlink(root.join("sub/link-root.js")?),
330 ),
331 (
332 "foo.js".into(),
333 DirectoryEntry::File(root.join("sub/foo.js")?),
334 ),
335 ])
336 );
337 assert_eq!(inner.inner.len(), 0);
338
339 let read_dir = root
341 .read_glob(Glob::new(rcstr!("sub/dir/*"), GlobOptions::default()))
342 .await
343 .unwrap();
344 assert_eq!(read_dir.results.len(), 0);
345 let inner_sub = &*read_dir.inner.get("sub").unwrap().await?;
346 assert_eq!(inner_sub.results.len(), 0);
347 let inner_sub_dir = &*inner_sub.inner.get("dir").unwrap().await?;
348 assert_eq!(
349 inner_sub_dir.results,
350 HashMap::from_iter([(
351 "index.js".into(),
352 DirectoryEntry::File(root.join("sub/dir/index.js")?),
353 )])
354 );
355 assert_eq!(inner_sub_dir.inner.len(), 0);
356
357 Ok(())
358 }
359
360 #[turbo_tasks::function(operation)]
361 async fn assert_dead_symlink_read_glob_operation(path: RcStr) -> anyhow::Result<()> {
362 let fs =
363 Vc::upcast::<Box<dyn FileSystem>>(DiskFileSystem::new(rcstr!("temp"), Vc::cell(path)));
364 let root = fs.root().owned().await?;
365 let read_dir = root
366 .read_glob(Glob::new(rcstr!("sub/*.js"), GlobOptions::default()))
367 .await?;
368 assert_eq!(read_dir.results.len(), 0);
369 assert_eq!(read_dir.inner.len(), 1);
370 let inner_sub = &*read_dir.inner.get("sub").unwrap().await?;
371 assert_eq!(inner_sub.inner.len(), 0);
372 assert_eq!(
373 inner_sub.results,
374 HashMap::from_iter([
375 (
376 "foo.js".into(),
377 DirectoryEntry::File(root.join("sub/foo.js")?),
378 ),
379 (
380 "dead_link.js".into(),
381 DirectoryEntry::Symlink(root.join("sub/dead_link.js")?),
382 )
383 ])
384 );
385
386 Ok(())
387 }
388
389 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
390 async fn read_glob_basic() {
391 let scratch = tempfile::tempdir().unwrap();
392 {
393 let path = scratch.path();
395 File::create_new(path.join("foo"))
396 .unwrap()
397 .write_all(b"foo")
398 .unwrap();
399 create_dir(path.join("sub")).unwrap();
400 File::create_new(path.join("sub/bar"))
401 .unwrap()
402 .write_all(b"bar")
403 .unwrap();
404 }
405 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
406 BackendOptions::default(),
407 noop_backing_storage(),
408 ));
409 let path: RcStr = scratch.path().to_str().unwrap().into();
410 tt.run_once(async {
411 assert_read_glob_basic_operation(path)
412 .read_strongly_consistent()
413 .await?;
414
415 anyhow::Ok(())
416 })
417 .await
418 .unwrap();
419 }
420
421 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
422 async fn read_glob_symlinks() {
423 let scratch = tempfile::tempdir().unwrap();
424 {
425 let path = scratch.path();
430 create_dir(path.join("sub")).unwrap();
431 let foo = path.join("sub/foo.js");
432 File::create_new(&foo).unwrap().write_all(b"foo").unwrap();
433 symlink(&foo, path.join("sub/link-foo.js")).unwrap();
434
435 let root = path.join("root.js");
436 File::create_new(&root).unwrap().write_all(b"root").unwrap();
437 symlink(&root, path.join("sub/link-root.js")).unwrap();
438
439 let dir = path.join("dir");
440 create_dir(&dir).unwrap();
441 File::create_new(dir.join("index.js"))
442 .unwrap()
443 .write_all(b"dir index")
444 .unwrap();
445 symlink(&dir, path.join("sub/dir")).unwrap();
446 }
447 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
448 BackendOptions::default(),
449 noop_backing_storage(),
450 ));
451 let path: RcStr = scratch.path().to_str().unwrap().into();
452 tt.run_once(async {
453 assert_read_glob_symlinks_operation(path)
454 .read_strongly_consistent()
455 .await?;
456
457 anyhow::Ok(())
458 })
459 .await
460 .unwrap();
461 }
462
463 #[turbo_tasks::function(operation)]
464 pub async fn delete(path: FileSystemPath) -> anyhow::Result<()> {
465 path.write(FileContent::NotFound.cell()).await?;
466 Ok(())
467 }
468
469 #[turbo_tasks::function(operation)]
470 pub async fn write(path: FileSystemPath, contents: RcStr) -> anyhow::Result<()> {
471 path.write(
472 FileContent::Content(crate::File::from_bytes(contents.to_string().into_bytes())).cell(),
473 )
474 .await?;
475 Ok(())
476 }
477
478 #[turbo_tasks::function(operation)]
479 pub fn track_star_star_glob(path: FileSystemPath) -> Vc<Completion> {
480 path.track_glob(Glob::new(rcstr!("**"), GlobOptions::default()), false)
481 }
482
483 #[turbo_tasks::function(operation)]
484 fn disk_file_system_root_operation(path: RcStr) -> Vc<FileSystemPath> {
485 let fs =
486 Vc::upcast::<Box<dyn FileSystem>>(DiskFileSystem::new(rcstr!("temp"), Vc::cell(path)));
487 fs.root()
488 }
489
490 #[turbo_tasks::function(operation)]
491 async fn extract_effects_operation(op: OperationVc<()>) -> anyhow::Result<Vc<Effects>> {
492 let _ = op.resolve().strongly_consistent().await?;
493 Ok(take_effects(op).await?.cell())
494 }
495
496 #[turbo_tasks::function(operation)]
497 async fn track_glob_operation(path: RcStr, glob: RcStr) -> anyhow::Result<()> {
498 let root = disk_file_system_root_operation(path)
499 .read_strongly_consistent()
500 .await?;
501 root.track_glob(Glob::new(glob, GlobOptions::default()), false)
502 .await?;
503 Ok(())
504 }
505
506 #[turbo_tasks::function(operation)]
507 async fn read_glob_operation(path: RcStr, glob: RcStr) -> anyhow::Result<()> {
508 let root = disk_file_system_root_operation(path)
509 .read_strongly_consistent()
510 .await?;
511 root.read_glob(Glob::new(glob, GlobOptions::default()))
512 .await?;
513 Ok(())
514 }
515
516 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
517 async fn track_glob_invalidations() {
518 let scratch = tempfile::tempdir().unwrap();
519
520 let path = scratch.path();
522 let dir = path.join("dir");
523 create_dir(&dir).unwrap();
524 File::create_new(dir.join("foo"))
525 .unwrap()
526 .write_all(b"foo")
527 .unwrap();
528 create_dir(dir.join("sub")).unwrap();
529 File::create_new(dir.join("sub/bar"))
530 .unwrap()
531 .write_all(b"bar")
532 .unwrap();
533 create_dir(dir.join("sub/.vim")).unwrap();
535 let gitignore = dir.join("sub/.vim/.gitignore");
536 File::create_new(&gitignore)
537 .unwrap()
538 .write_all(b"ignore")
539 .unwrap();
540 let link_target = path.join("link_target.js");
542 File::create_new(&link_target)
543 .unwrap()
544 .write_all(b"link_target")
545 .unwrap();
546 symlink(&link_target, dir.join("link.js")).unwrap();
547
548 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
549 BackendOptions::default(),
550 noop_backing_storage(),
551 ));
552 let path: RcStr = scratch.path().to_str().unwrap().into();
553 tt.run_once(async {
554 let root = disk_file_system_root_operation(path)
555 .read_strongly_consistent()
556 .await?;
557 let dir = root.join("dir")?;
558 let read_dir = track_star_star_glob(dir.clone())
559 .read_strongly_consistent()
560 .await?;
561
562 extract_effects_operation(delete(root.join("dir/sub/.vim/.gitignore")?))
564 .read_strongly_consistent()
565 .await?
566 .apply()
567 .await?;
568
569 let read_dir2 = track_star_star_glob(dir.clone())
570 .read_strongly_consistent()
571 .await?;
572 assert!(ReadRef::ptr_eq(&read_dir, &read_dir2));
573
574 extract_effects_operation(delete(root.join("dir/foo")?))
576 .read_strongly_consistent()
577 .await?
578 .apply()
579 .await?;
580
581 let read_dir2 = track_star_star_glob(dir.clone())
582 .read_strongly_consistent()
583 .await?;
584
585 assert!(!ReadRef::ptr_eq(&read_dir, &read_dir2));
586
587 extract_effects_operation(write(root.join("link_target.js")?, rcstr!("new_contents")))
589 .read_strongly_consistent()
590 .await?
591 .apply()
592 .await?;
593 let read_dir3 = track_star_star_glob(dir.clone())
594 .read_strongly_consistent()
595 .await?;
596
597 assert!(!ReadRef::ptr_eq(&read_dir3, &read_dir2));
598
599 anyhow::Ok(())
600 })
601 .await
602 .unwrap();
603 }
604
605 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
606 async fn track_glob_symlinks_loop() {
607 let scratch = tempfile::tempdir().unwrap();
608 {
609 let path = scratch.path();
612 let sub = &path.join("sub");
613 create_dir(sub).unwrap();
614 let foo = sub.join("foo.js");
615 File::create_new(&foo).unwrap().write_all(b"foo").unwrap();
616 symlink(sub, sub.join("link")).unwrap();
618 }
619 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
620 BackendOptions::default(),
621 noop_backing_storage(),
622 ));
623 let path: RcStr = scratch.path().to_str().unwrap().into();
624 tt.run_once(async {
625 let err = track_glob_operation(path.clone(), rcstr!("**"))
626 .read_strongly_consistent()
627 .await
628 .expect_err("Should have detected an infinite loop");
629
630 assert_eq!(
631 "'sub/link' is a symlink causes that causes an infinite loop!",
632 format!("{}", err.root_cause())
633 );
634
635 let err = track_glob_operation(path, rcstr!("**"))
637 .read_strongly_consistent()
638 .await
639 .expect_err("Should have detected an infinite loop");
640
641 assert_eq!(
642 "'sub/link' is a symlink causes that causes an infinite loop!",
643 format!("{}", err.root_cause())
644 );
645
646 anyhow::Ok(())
647 })
648 .await
649 .unwrap();
650 }
651
652 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
654 async fn dead_symlinks() {
655 let scratch = tempfile::tempdir().unwrap();
656 {
657 let path = scratch.path();
659 let sub = &path.join("sub");
660 create_dir(sub).unwrap();
661 let foo = sub.join("foo.js");
662 File::create_new(&foo).unwrap().write_all(b"foo").unwrap();
663 symlink(sub.join("doesntexist.js"), sub.join("dead_link.js")).unwrap();
665 }
666 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
667 BackendOptions::default(),
668 noop_backing_storage(),
669 ));
670 let path: RcStr = scratch.path().to_str().unwrap().into();
671 tt.run_once(async {
672 track_glob_operation(path, rcstr!("sub/*.js"))
673 .read_strongly_consistent()
674 .await?;
675 anyhow::Ok(())
676 })
677 .await
678 .unwrap();
679 let path: RcStr = scratch.path().to_str().unwrap().into();
680 tt.run_once(async {
681 assert_dead_symlink_read_glob_operation(path)
682 .read_strongly_consistent()
683 .await?;
684 anyhow::Ok(())
685 })
686 .await
687 .unwrap();
688 }
689
690 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
692 async fn symlink_escapes_fs_root() {
693 let scratch = tempfile::tempdir().unwrap();
694 {
695 let path = scratch.path();
697 let sub = &path.join("sub");
698 create_dir(sub).unwrap();
699 let foo = scratch.path().join("foo.js");
700 File::create_new(&foo).unwrap().write_all(b"foo").unwrap();
701 symlink(foo, sub.join("escape.js")).unwrap();
703 }
704 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
705 BackendOptions::default(),
706 noop_backing_storage(),
707 ));
708 let root: RcStr = scratch.path().join("sub").to_str().unwrap().into();
709 tt.run_once(async {
710 track_glob_operation(root, rcstr!("*.js"))
711 .read_strongly_consistent()
712 .await?;
713 anyhow::Ok(())
714 })
715 .await
716 .unwrap();
717 }
718
719 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
720 async fn read_glob_symlinks_loop() {
721 let scratch = tempfile::tempdir().unwrap();
722 {
723 let path = scratch.path();
726 let sub = &path.join("sub");
727 create_dir(sub).unwrap();
728 let foo = sub.join("foo.js");
729 File::create_new(&foo).unwrap().write_all(b"foo").unwrap();
730 symlink(sub, sub.join("link")).unwrap();
732 }
733 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
734 BackendOptions::default(),
735 noop_backing_storage(),
736 ));
737 let path: RcStr = scratch.path().to_str().unwrap().into();
738 tt.run_once(async {
739 let err = read_glob_operation(path.clone(), rcstr!("**"))
740 .read_strongly_consistent()
741 .await
742 .expect_err("Should have detected an infinite loop");
743
744 assert_eq!(
745 "'sub/link' is a symlink causes that causes an infinite loop!",
746 format!("{}", err.root_cause())
747 );
748
749 let err = track_glob_operation(path, rcstr!("**"))
751 .read_strongly_consistent()
752 .await
753 .expect_err("Should have detected an infinite loop");
754
755 assert_eq!(
756 "'sub/link' is a symlink causes that causes an infinite loop!",
757 format!("{}", err.root_cause())
758 );
759
760 anyhow::Ok(())
761 })
762 .await
763 .unwrap();
764 }
765}