[go: up one dir, main page]

tauri 2.9.1

Make tiny, secure apps for all desktop platforms with Tauri
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use std::{
  collections::{HashMap, HashSet},
  fmt,
  path::{Path, PathBuf, MAIN_SEPARATOR},
  sync::{
    atomic::{AtomicU32, Ordering},
    Arc, Mutex,
  },
};

use tauri_utils::config::FsScope;

use crate::ScopeEventId;

pub use glob::Pattern;

/// Scope change event.
#[derive(Debug, Clone)]
pub enum Event {
  /// A path has been allowed.
  PathAllowed(PathBuf),
  /// A path has been forbidden.
  PathForbidden(PathBuf),
}

type EventListener = Box<dyn Fn(&Event) + Send>;

/// Scope for filesystem access.
#[derive(Clone)]
pub struct Scope {
  allowed_patterns: Arc<Mutex<HashSet<Pattern>>>,
  forbidden_patterns: Arc<Mutex<HashSet<Pattern>>>,
  event_listeners: Arc<Mutex<HashMap<ScopeEventId, EventListener>>>,
  match_options: glob::MatchOptions,
  next_event_id: Arc<AtomicU32>,
}

impl Scope {
  fn next_event_id(&self) -> u32 {
    self.next_event_id.fetch_add(1, Ordering::Relaxed)
  }
}

impl fmt::Debug for Scope {
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    f.debug_struct("Scope")
      .field(
        "allowed_patterns",
        &self
          .allowed_patterns
          .lock()
          .unwrap()
          .iter()
          .map(|p| p.as_str())
          .collect::<Vec<&str>>(),
      )
      .field(
        "forbidden_patterns",
        &self
          .forbidden_patterns
          .lock()
          .unwrap()
          .iter()
          .map(|p| p.as_str())
          .collect::<Vec<&str>>(),
      )
      .finish()
  }
}

fn push_pattern<P: AsRef<Path>, F: Fn(&str) -> Result<Pattern, glob::PatternError>>(
  list: &mut HashSet<Pattern>,
  pattern: P,
  f: F,
) -> crate::Result<()> {
  // Reconstruct pattern path components with appropraite separator
  // so `some\path/to/dir/**\*` would be `some/path/to/dir/**/*` on Unix
  // and  `some\path\to\dir\**\*` on Windows.
  let path: PathBuf = pattern.as_ref().components().collect();

  // Add pattern as is to be matched with paths as is
  let path_str = path.to_string_lossy();
  list.insert(f(&path_str)?);

  // On Windows, if path starts with a Prefix, try to strip it if possible
  // so `\\?\C:\\SomeDir` would result in a scope of:
  //   - `\\?\C:\\SomeDir`
  //   - `C:\\SomeDir`
  #[cfg(windows)]
  {
    use std::path::{Component, Prefix};

    let mut components = path.components();

    let is_unc = match components.next() {
      Some(Component::Prefix(p)) => match p.kind() {
        Prefix::VerbatimDisk(..) => true,
        _ => false, // Other kinds of UNC paths
      },
      _ => false, // relative or empty
    };

    if is_unc {
      // we remove UNC manually, instead of `dunce::simplified` because
      // `path` could have `*` in it and that's not allowed on Windows and
      // `dunce::simplified` will check that and return `path` as is without simplification
      let simplified = path
        .to_str()
        .and_then(|s| s.get(4..))
        .map_or(path.as_path(), Path::new);

      let simplified_str = simplified.to_string_lossy();
      if simplified_str != path_str {
        list.insert(f(&simplified_str)?);
      }
    }
  }

  // Add canonicalized version of the pattern or canonicalized version of its parents
  // so `/data/user/0/appid/assets/*` would be canonicalized to `/data/data/appid/assets/*`
  // and can then be matched against any of them.
  if let Some(p) = canonicalize_parent(path) {
    list.insert(f(&p.to_string_lossy())?);
  }

  Ok(())
}

/// Attempt to canonicalize path or its parents in case we have a path like `/data/user/0/appid/**`
/// where `**` obviously does not exist but we need to canonicalize the parent.
///
/// example: given the `/data/user/0/appid/assets/*` path,
/// it's a glob pattern so it won't exist (std::fs::canonicalize() fails);
///
/// the second iteration needs to check `/data/user/0/appid/assets` and save the `*` component to append later.
///
/// if it also does not exist, a third iteration is required to check `/data/user/0/appid`
/// with `assets/*` as the cached value (`checked_path` variable)
/// on Android that gets canonicalized to `/data/data/appid` so the final value will be `/data/data/appid/assets/*`
/// which is the value we want to check when we execute the `Scope::is_allowed` function
fn canonicalize_parent(mut path: PathBuf) -> Option<PathBuf> {
  let mut failed_components = None;

  loop {
    if let Ok(path) = path.canonicalize() {
      break Some(if let Some(p) = failed_components {
        path.join(p)
      } else {
        path
      });
    }

    // grap the last component of the path
    if let Some(mut last) = path.iter().next_back().map(PathBuf::from) {
      // remove the last component of the path so the next iteration checks its parent
      // if there is no more parent components, we failed to canonicalize
      if !path.pop() {
        break None;
      }

      // append the already checked path to the last component
      // to construct `<last>/<checked_path>` and saved it for next iteration
      if let Some(failed_components) = &failed_components {
        last.push(failed_components);
      }
      failed_components.replace(last);
    } else {
      break None;
    }
  }
}
impl Scope {
  /// Creates a new scope from a [`FsScope`] configuration.
  pub fn new<R: crate::Runtime, M: crate::Manager<R>>(
    manager: &M,
    scope: &FsScope,
  ) -> crate::Result<Self> {
    let mut allowed_patterns = HashSet::new();
    for path in scope.allowed_paths() {
      if let Ok(path) = manager.path().parse(path) {
        push_pattern(&mut allowed_patterns, path, Pattern::new)?;
      }
    }

    let mut forbidden_patterns = HashSet::new();
    if let Some(forbidden_paths) = scope.forbidden_paths() {
      for path in forbidden_paths {
        if let Ok(path) = manager.path().parse(path) {
          push_pattern(&mut forbidden_patterns, path, Pattern::new)?;
        }
      }
    }

    let require_literal_leading_dot = match scope {
      FsScope::Scope {
        require_literal_leading_dot: Some(require),
        ..
      } => *require,
      // dotfiles are not supposed to be exposed by default on unix
      #[cfg(unix)]
      _ => true,
      #[cfg(windows)]
      _ => false,
    };

    Ok(Self {
      allowed_patterns: Arc::new(Mutex::new(allowed_patterns)),
      forbidden_patterns: Arc::new(Mutex::new(forbidden_patterns)),
      event_listeners: Default::default(),
      next_event_id: Default::default(),
      match_options: glob::MatchOptions {
        // this is needed so `/dir/*` doesn't match files within subdirectories such as `/dir/subdir/file.txt`
        // see: <https://github.com/tauri-apps/tauri/security/advisories/GHSA-6mv3-wm7j-h4w5>
        require_literal_separator: true,
        require_literal_leading_dot,
        ..Default::default()
      },
    })
  }

  /// The list of allowed patterns.
  pub fn allowed_patterns(&self) -> HashSet<Pattern> {
    self.allowed_patterns.lock().unwrap().clone()
  }

  /// The list of forbidden patterns.
  pub fn forbidden_patterns(&self) -> HashSet<Pattern> {
    self.forbidden_patterns.lock().unwrap().clone()
  }

  /// Listen to an event on this scope.
  pub fn listen<F: Fn(&Event) + Send + 'static>(&self, f: F) -> ScopeEventId {
    let id = self.next_event_id();
    self.listen_with_id(id, f);
    id
  }

  fn listen_with_id<F: Fn(&Event) + Send + 'static>(&self, id: ScopeEventId, f: F) {
    self.event_listeners.lock().unwrap().insert(id, Box::new(f));
  }

  /// Listen to an event on this scope and immediately unlisten.
  pub fn once<F: FnOnce(&Event) + Send + 'static>(&self, f: F) -> ScopeEventId {
    let listerners = self.event_listeners.clone();
    let handler = std::cell::Cell::new(Some(f));
    let id = self.next_event_id();
    self.listen_with_id(id, move |event| {
      listerners.lock().unwrap().remove(&id);
      let handler = handler
        .take()
        .expect("attempted to call handler more than once");
      handler(event)
    });
    id
  }

  /// Removes an event listener on this scope.
  pub fn unlisten(&self, id: ScopeEventId) {
    self.event_listeners.lock().unwrap().remove(&id);
  }

  fn emit(&self, event: Event) {
    let listeners = self.event_listeners.lock().unwrap();
    let handlers = listeners.values();
    for listener in handlers {
      listener(&event);
    }
  }

  /// Extend the allowed patterns with the given directory.
  ///
  /// After this function has been called, the frontend will be able to use the Tauri API to read
  /// the directory and all of its files. If `recursive` is `true`, subdirectories will be accessible too.
  pub fn allow_directory<P: AsRef<Path>>(&self, path: P, recursive: bool) -> crate::Result<()> {
    let path = path.as_ref();
    {
      let mut list = self.allowed_patterns.lock().unwrap();

      // allow the directory to be read
      push_pattern(&mut list, path, escaped_pattern)?;
      // allow its files and subdirectories to be read
      push_pattern(&mut list, path, |p| {
        escaped_pattern_with(p, if recursive { "**" } else { "*" })
      })?;
    }
    self.emit(Event::PathAllowed(path.to_path_buf()));
    Ok(())
  }

  /// Extend the allowed patterns with the given file path.
  ///
  /// After this function has been called, the frontend will be able to use the Tauri API to read the contents of this file.
  pub fn allow_file<P: AsRef<Path>>(&self, path: P) -> crate::Result<()> {
    let path = path.as_ref();
    push_pattern(
      &mut self.allowed_patterns.lock().unwrap(),
      path,
      escaped_pattern,
    )?;
    self.emit(Event::PathAllowed(path.to_path_buf()));
    Ok(())
  }

  /// Set the given directory path to be forbidden by this scope.
  ///
  /// **Note:** this takes precedence over allowed paths, so its access gets denied **always**.
  pub fn forbid_directory<P: AsRef<Path>>(&self, path: P, recursive: bool) -> crate::Result<()> {
    let path = path.as_ref();
    {
      let mut list = self.forbidden_patterns.lock().unwrap();

      // allow the directory to be read
      push_pattern(&mut list, path, escaped_pattern)?;
      // allow its files and subdirectories to be read
      push_pattern(&mut list, path, |p| {
        escaped_pattern_with(p, if recursive { "**" } else { "*" })
      })?;
    }
    self.emit(Event::PathForbidden(path.to_path_buf()));
    Ok(())
  }

  /// Set the given file path to be forbidden by this scope.
  ///
  /// **Note:** this takes precedence over allowed paths, so its access gets denied **always**.
  pub fn forbid_file<P: AsRef<Path>>(&self, path: P) -> crate::Result<()> {
    let path = path.as_ref();
    push_pattern(
      &mut self.forbidden_patterns.lock().unwrap(),
      path,
      escaped_pattern,
    )?;
    self.emit(Event::PathForbidden(path.to_path_buf()));
    Ok(())
  }

  /// Determines if the given path is allowed on this scope.
  ///
  /// Returns `false` if the path was explicitly forbidden or neither allowed nor forbidden.
  ///
  /// May return `false` if the path points to a broken symlink.
  pub fn is_allowed<P: AsRef<Path>>(&self, path: P) -> bool {
    let path = try_resolve_symlink_and_canonicalize(path);

    if let Ok(path) = path {
      let path: PathBuf = path.components().collect();
      let forbidden = self
        .forbidden_patterns
        .lock()
        .unwrap()
        .iter()
        .any(|p| p.matches_path_with(&path, self.match_options));

      if forbidden {
        false
      } else {
        let allowed = self
          .allowed_patterns
          .lock()
          .unwrap()
          .iter()
          .any(|p| p.matches_path_with(&path, self.match_options));

        allowed
      }
    } else {
      false
    }
  }

  /// Determines if the given path is explicitly forbidden on this scope.
  ///
  /// May return `true` if the path points to a broken symlink.
  pub fn is_forbidden<P: AsRef<Path>>(&self, path: P) -> bool {
    let path = try_resolve_symlink_and_canonicalize(path);

    if let Ok(path) = path {
      let path: PathBuf = path.components().collect();
      self
        .forbidden_patterns
        .lock()
        .unwrap()
        .iter()
        .any(|p| p.matches_path_with(&path, self.match_options))
    } else {
      true
    }
  }
}

fn try_resolve_symlink_and_canonicalize<P: AsRef<Path>>(path: P) -> crate::Result<PathBuf> {
  let path = path.as_ref();
  let path = if path.is_symlink() {
    std::fs::read_link(path)?
  } else {
    path.to_path_buf()
  };
  if !path.exists() {
    crate::Result::Ok(path)
  } else {
    std::fs::canonicalize(path).map_err(Into::into)
  }
}

fn escaped_pattern(p: &str) -> Result<Pattern, glob::PatternError> {
  Pattern::new(&glob::Pattern::escape(p))
}

fn escaped_pattern_with(p: &str, append: &str) -> Result<Pattern, glob::PatternError> {
  if p.ends_with(MAIN_SEPARATOR) {
    Pattern::new(&format!("{}{append}", glob::Pattern::escape(p)))
  } else {
    Pattern::new(&format!(
      "{}{}{append}",
      glob::Pattern::escape(p),
      MAIN_SEPARATOR
    ))
  }
}

#[cfg(test)]
mod tests {
  use std::collections::HashSet;

  use glob::Pattern;

  use super::{push_pattern, Scope};

  fn new_scope() -> Scope {
    Scope {
      allowed_patterns: Default::default(),
      forbidden_patterns: Default::default(),
      event_listeners: Default::default(),
      next_event_id: Default::default(),
      match_options: glob::MatchOptions {
        // this is needed so `/dir/*` doesn't match files within subdirectories such as `/dir/subdir/file.txt`
        // see: <https://github.com/tauri-apps/tauri/security/advisories/GHSA-6mv3-wm7j-h4w5>
        require_literal_separator: true,
        // dotfiles are not supposed to be exposed by default on unix
        #[cfg(unix)]
        require_literal_leading_dot: true,
        #[cfg(windows)]
        require_literal_leading_dot: false,
        ..Default::default()
      },
    }
  }

  #[test]
  fn path_is_escaped() {
    let scope = new_scope();
    #[cfg(unix)]
    {
      scope.allow_directory("/home/tauri/**", false).unwrap();
      assert!(scope.is_allowed("/home/tauri/**"));
      assert!(scope.is_allowed("/home/tauri/**/file"));
      assert!(!scope.is_allowed("/home/tauri/anyfile"));
    }
    #[cfg(windows)]
    {
      scope.allow_directory("C:\\home\\tauri\\**", false).unwrap();
      assert!(scope.is_allowed("C:\\home\\tauri\\**"));
      assert!(scope.is_allowed("C:\\home\\tauri\\**\\file"));
      assert!(!scope.is_allowed("C:\\home\\tauri\\anyfile"));
    }

    let scope = new_scope();
    #[cfg(unix)]
    {
      scope.allow_file("/home/tauri/**").unwrap();
      assert!(scope.is_allowed("/home/tauri/**"));
      assert!(!scope.is_allowed("/home/tauri/**/file"));
      assert!(!scope.is_allowed("/home/tauri/anyfile"));
    }
    #[cfg(windows)]
    {
      scope.allow_file("C:\\home\\tauri\\**").unwrap();
      assert!(scope.is_allowed("C:\\home\\tauri\\**"));
      assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file"));
      assert!(!scope.is_allowed("C:\\home\\tauri\\anyfile"));
    }

    let scope = new_scope();
    #[cfg(unix)]
    {
      scope.allow_directory("/home/tauri", true).unwrap();
      scope.forbid_directory("/home/tauri/**", false).unwrap();
      assert!(!scope.is_allowed("/home/tauri/**"));
      assert!(!scope.is_allowed("/home/tauri/**/file"));
      assert!(scope.is_allowed("/home/tauri/**/inner/file"));
      assert!(scope.is_allowed("/home/tauri/inner/folder/anyfile"));
      assert!(scope.is_allowed("/home/tauri/anyfile"));
    }
    #[cfg(windows)]
    {
      scope.allow_directory("C:\\home\\tauri", true).unwrap();
      scope
        .forbid_directory("C:\\home\\tauri\\**", false)
        .unwrap();
      assert!(!scope.is_allowed("C:\\home\\tauri\\**"));
      assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file"));
      assert!(scope.is_allowed("C:\\home\\tauri\\**\\inner\\file"));
      assert!(scope.is_allowed("C:\\home\\tauri\\inner\\folder\\anyfile"));
      assert!(scope.is_allowed("C:\\home\\tauri\\anyfile"));
    }

    let scope = new_scope();
    #[cfg(unix)]
    {
      scope.allow_directory("/home/tauri", true).unwrap();
      scope.forbid_file("/home/tauri/**").unwrap();
      assert!(!scope.is_allowed("/home/tauri/**"));
      assert!(scope.is_allowed("/home/tauri/**/file"));
      assert!(scope.is_allowed("/home/tauri/**/inner/file"));
      assert!(scope.is_allowed("/home/tauri/anyfile"));
    }
    #[cfg(windows)]
    {
      scope.allow_directory("C:\\home\\tauri", true).unwrap();
      scope.forbid_file("C:\\home\\tauri\\**").unwrap();
      assert!(!scope.is_allowed("C:\\home\\tauri\\**"));
      assert!(scope.is_allowed("C:\\home\\tauri\\**\\file"));
      assert!(scope.is_allowed("C:\\home\\tauri\\**\\inner\\file"));
      assert!(scope.is_allowed("C:\\home\\tauri\\anyfile"));
    }

    let scope = new_scope();
    #[cfg(unix)]
    {
      scope.allow_directory("/home/tauri", false).unwrap();
      assert!(scope.is_allowed("/home/tauri/**"));
      assert!(!scope.is_allowed("/home/tauri/**/file"));
      assert!(!scope.is_allowed("/home/tauri/**/inner/file"));
      assert!(scope.is_allowed("/home/tauri/anyfile"));
    }
    #[cfg(windows)]
    {
      scope.allow_directory("C:\\home\\tauri", false).unwrap();
      assert!(scope.is_allowed("C:\\home\\tauri\\**"));
      assert!(!scope.is_allowed("C:\\home\\tauri\\**\\file"));
      assert!(!scope.is_allowed("C:\\home\\tauri\\**\\inner\\file"));
      assert!(scope.is_allowed("C:\\home\\tauri\\anyfile"));
    }
  }

  #[cfg(windows)]
  #[test]
  fn windows_root_paths() {
    let scope = new_scope();
    {
      // UNC network path
      scope.allow_directory("\\\\localhost\\c$", true).unwrap();
      assert!(scope.is_allowed("\\\\localhost\\c$"));
      assert!(scope.is_allowed("\\\\localhost\\c$\\Windows"));
      assert!(scope.is_allowed("\\\\localhost\\c$\\NonExistentFile"));
      assert!(!scope.is_allowed("\\\\localhost\\d$"));
      assert!(!scope.is_allowed("\\\\OtherServer\\Share"));
    }

    let scope = new_scope();
    {
      // Verbatim UNC network path
      scope
        .allow_directory("\\\\?\\UNC\\localhost\\c$", true)
        .unwrap();
      assert!(scope.is_allowed("\\\\localhost\\c$"));
      assert!(scope.is_allowed("\\\\localhost\\c$\\Windows"));
      assert!(scope.is_allowed("\\\\?\\UNC\\localhost\\c$\\Windows\\NonExistentFile"));
      // A non-existent file cannot be canonicalized to a verbatim UNC path, so this will fail to match
      assert!(!scope.is_allowed("\\\\localhost\\c$\\Windows\\NonExistentFile"));
      assert!(!scope.is_allowed("\\\\localhost\\d$"));
      assert!(!scope.is_allowed("\\\\OtherServer\\Share"));
    }

    let scope = new_scope();
    {
      // Device namespace
      scope.allow_file("\\\\.\\COM1").unwrap();
      assert!(scope.is_allowed("\\\\.\\COM1"));
      assert!(!scope.is_allowed("\\\\.\\COM2"));
    }

    let scope = new_scope();
    {
      // Disk root
      scope.allow_directory("C:\\", true).unwrap();
      assert!(scope.is_allowed("C:\\Windows"));
      assert!(scope.is_allowed("C:\\Windows\\system.ini"));
      assert!(scope.is_allowed("C:\\NonExistentFile"));
      assert!(!scope.is_allowed("D:\\home"));
    }

    let scope = new_scope();
    {
      // Verbatim disk root
      scope.allow_directory("\\\\?\\C:\\", true).unwrap();
      assert!(scope.is_allowed("C:\\Windows"));
      assert!(scope.is_allowed("C:\\Windows\\system.ini"));
      assert!(scope.is_allowed("C:\\NonExistentFile"));
      assert!(!scope.is_allowed("D:\\home"));
    }

    let scope = new_scope();
    {
      // Verbatim path
      scope.allow_file("\\\\?\\anyfile").unwrap();
      assert!(scope.is_allowed("\\\\?\\anyfile"));
      assert!(!scope.is_allowed("\\\\?\\otherfile"));
    }
  }

  #[test]
  fn push_pattern_generated_paths() {
    macro_rules! assert_pattern {
      ($patterns:ident, $pattern:literal) => {
        assert!($patterns.contains(&Pattern::new($pattern).unwrap()))
      };
    }

    let mut patterns = HashSet::new();

    #[cfg(not(windows))]
    {
      push_pattern(&mut patterns, "/path/to/dir/", Pattern::new).expect("failed to push pattern");
      push_pattern(&mut patterns, "/path/to/dir/**", Pattern::new).expect("failed to push pattern");

      assert_pattern!(patterns, "/path/to/dir");
      assert_pattern!(patterns, "/path/to/dir/**");
    }

    #[cfg(windows)]
    {
      push_pattern(&mut patterns, "C:\\path\\to\\dir", Pattern::new)
        .expect("failed to push pattern");
      push_pattern(&mut patterns, "C:\\path\\to\\dir\\**", Pattern::new)
        .expect("failed to push pattern");

      assert_pattern!(patterns, "C:\\path\\to\\dir");
      assert_pattern!(patterns, "C:\\path\\to\\dir\\**");
      assert_pattern!(patterns, "\\\\?\\C:\\path\\to\\dir");
      assert_pattern!(patterns, "\\\\?\\C:\\path\\to\\dir\\**");
    }
  }
}