Skip to main content

ic_asset_certification/
asset_config.rs

1use crate::{Asset, AssetCertificationError};
2use globset::{Glob, GlobMatcher};
3use ic_http_certification::StatusCode;
4use std::fmt::{Display, Formatter};
5
6/// Certification configuration for [assets](Asset). This configuration
7/// is passed alongside the [assets](Asset) to the
8/// [AssetRouter](crate::AssetRouter).
9///
10/// The configuration can target a specific [File](AssetConfig::File) or an
11/// array of files using a [Pattern](AssetConfig::Pattern).
12///
13/// Configuration can specify the content type and
14/// headers to include for certification and to be served by the
15/// [AssetRouter](crate::AssetRouter) for each asset matching the configuration.
16///
17/// # Examples
18///
19/// ## JavaScript file
20///
21/// This example configures an individual JavaScript file to be served by the
22/// [AssetRouter](crate::AssetRouter) on the `/app.js` path. The content type is
23/// set to `text/javascript` and a `cache-control` header is added.
24///
25/// ```
26/// use ic_http_certification::StatusCode;
27/// use ic_asset_certification::{AssetConfig, AssetEncoding};
28///
29/// let config = AssetConfig::File {
30///     path: "app.js".to_string(),
31///     content_type: Some("text/javascript".to_string()),
32///     headers: vec![
33///         ("Cache-Control".to_string(), "public, max-age=31536000, immutable".to_string()),
34///     ],
35///     fallback_for: vec![],
36///     aliased_by: vec![],
37///     encodings: vec![
38///         AssetEncoding::Brotli.default_config(),
39///         AssetEncoding::Gzip.default_config(),
40///     ],
41/// };
42/// ```
43///
44/// ## Index HTML file with fallback
45///
46/// This example configures an individual HTML file to be served by the
47/// [AssetRouter](crate::AssetRouter) on the `/index.html` path. In addition,
48/// it is configured as the fallback for the `/` scope. This means that any
49/// request that does not exactly match an asset, will be given this response.
50/// The content type is set to `text/html` and a `cache-control` header is added.
51///
52/// ```
53/// use ic_http_certification::StatusCode;
54/// use ic_asset_certification::{AssetConfig, AssetFallbackConfig, AssetEncoding};
55///
56/// let config = AssetConfig::File {
57///     path: "index.html".to_string(),
58///     content_type: Some("text/html".to_string()),
59///     headers: vec![
60///         ("Cache-Control".to_string(), "public, no-cache, no-store".to_string()),
61///     ],
62///     fallback_for: vec![AssetFallbackConfig {
63///         scope: "/".to_string(),
64///         status_code: Some(StatusCode::OK),
65///     }],
66///     aliased_by: vec!["/".to_string()],
67///     encodings: vec![
68///         AssetEncoding::Brotli.default_config(),
69///         AssetEncoding::Gzip.default_config(),
70///     ],
71/// };
72/// ```
73///
74/// ## 404 HTML file with multiple fallbacks and aliases
75///
76/// This example configures an individual HTML file to be served by the
77/// [AssetRouter](crate::AssetRouter) on the `/404.html` path.
78///
79/// In addition, it is configured as the fallback for the `/js`, and `/css`
80/// scopes. This means that any request that does not exactly match an asset in
81/// the `/js` or `/css` directories, will be given this response. The content
82/// type is set to `text/html` and a `cache-control` header is added.
83///
84/// The asset is also aliased by multiple paths. This means that any request
85/// made to one of these aliases will be served the asset at `/404.html`.
86/// The asset is aliased by the following paths:
87///     - `/404`
88///     - `/404/`
89///     - `/404.html`
90///     - `/not-found`
91///     - `/not-found/`
92///     - `/not-found/index.html`
93///
94/// ```
95/// use ic_http_certification::StatusCode;
96/// use ic_asset_certification::{AssetConfig, AssetFallbackConfig, AssetEncoding};
97///
98/// let config = AssetConfig::File {
99///     path: "404.html".to_string(),
100///     content_type: Some("text/html".to_string()),
101///     headers: vec![
102///         ("Cache-Control".to_string(), "public, no-cache, no-store".to_string()),
103///     ],
104///     fallback_for: vec![
105///         AssetFallbackConfig {
106///             scope: "/css".to_string(),
107///             status_code: Some(StatusCode::NOT_FOUND),
108///         },
109///         AssetFallbackConfig {
110///             scope: "/js".to_string(),
111///             status_code: Some(StatusCode::NOT_FOUND),
112///         },
113///     ],
114///     aliased_by: vec![
115///         "/404".to_string(),
116///         "/404/".to_string(),
117///         "/404.html".to_string(),
118///         "/not-found".to_string(),
119///         "/not-found/".to_string(),
120///         "/not-found/index.html".to_string(),
121///    ],
122///     encodings: vec![
123///         AssetEncoding::Brotli.default_config(),
124///         AssetEncoding::Gzip.default_config(),
125///     ],
126/// };
127/// ```
128///
129/// ## CSS files using a glob pattern
130///
131/// This example configures all CSS files to be served by the
132/// [AssetRouter](crate::AssetRouter) using a glob pattern. The content type is
133/// set to `text/css` and a `cache-control` header is added.
134///
135/// ```
136/// use ic_http_certification::StatusCode;
137/// use ic_asset_certification::{AssetConfig, AssetEncoding};
138///
139/// let config = AssetConfig::Pattern {
140///     pattern: "**/*.css".to_string(),
141///     content_type: Some("text/css".to_string()),
142///     headers: vec![
143///         ("Cache-Control".to_string(), "public, max-age=31536000, immutable".to_string()),
144///     ],
145///     encodings: vec![
146///         AssetEncoding::Brotli.default_config(),
147///         AssetEncoding::Gzip.default_config(),
148///     ],
149/// };
150/// ```
151///
152/// ## Temporary redirect
153///
154/// This example configures a redirect from `/old` to `/new`. The redirect is
155/// configured as a temporary redirect (307).
156///
157/// ```
158/// use ic_asset_certification::{AssetConfig, AssetRedirectKind};
159///
160/// let config = AssetConfig::Redirect {
161///     from: "/old".to_string(),
162///     to: "/new".to_string(),
163///     kind: AssetRedirectKind::Temporary,
164///     headers: vec![(
165///         "content-type".to_string(),
166///         "text/plain; charset=utf-8".to_string(),
167///     )],
168/// };
169/// ```
170///
171/// ## Permanent redirect
172///
173/// This example configures a redirect from `/old` to `/new`. The redirect is
174/// configured as a permanent redirect (301).
175///
176/// ```
177/// use ic_asset_certification::{AssetConfig, AssetRedirectKind};
178///
179/// let config = AssetConfig::Redirect {
180///     from: "/old".to_string(),
181///     to: "/new".to_string(),
182///     kind: AssetRedirectKind::Permanent,
183///     headers: vec![(
184///         "content-type".to_string(),
185///         "text/plain; charset=utf-8".to_string(),
186///     )],
187/// };
188/// ```
189#[derive(Debug, Clone)]
190pub enum AssetConfig {
191    /// Matches a specific file.
192    File {
193        /// The path to the file. This path must exactly match the
194        /// path of an [Asset] provided to the [AssetRouter](crate::AssetRouter)
195        /// with this config.
196        path: String,
197
198        /// The content type of the file (e.g. "text/javascript").
199        ///
200        /// Providing this option will auto-insert a `Content-Type` header with
201        /// the provided value. If this value is not provided, the
202        /// `Content-Type` header will not be inserted.
203        ///
204        /// If the `Content-Type` header is not sent to the browser, the browser
205        /// will try to guess the content type based on the file extension,
206        /// unless a `X-Content-Type-Options: nosniff` header is sent.
207        ///
208        /// Not certifying the `Content-Type` header will also allow a malicious
209        /// replica to insert its own `Content-Type` header, which could lead
210        /// to a security vulnerability.
211        content_type: Option<String>,
212
213        /// Additional headers to be inserted into the response. Each additional
214        /// header added will be included in certification and served by the
215        /// [AssetRouter](crate::AssetRouter) for matching [Assets](Asset).
216        headers: Vec<(String, String)>,
217
218        /// Configure this asset as a fallback for a set of scopes.
219        ///
220        /// When serving assets, if a requested path does not exactly match any
221        /// assets then the [AssetRouter](crate::AssetRouter) will search for an
222        /// asset configured with a fallback scope that most closely matches
223        /// the requested asset's path.
224        ///
225        /// For example, if a request is made for `/app.js` and no asset with
226        /// that exact path is found, the router will attempt to serve an asset
227        /// configured with a fallback scope of `/`.
228        ///
229        /// This will be done recursively until no more fallback scopes are
230        /// possible to find. For example, if a request is made for
231        /// `/assets/js/app/core/index.js` and no asset with that exact path is
232        /// found, the [AssetRouter](crate::AssetRouter) will search for assets
233        /// configured with the following fallback scopes, in order:
234        /// - `/assets/js/app/core`
235        /// - `/assets/js/app`
236        /// - `/assets/js`
237        /// - `/assets`
238        /// - `/`
239        ///
240        /// If multiple fallback assets are configured, the first one found will
241        /// be used. If no asset is found with any of these fallback scopes, no
242        /// response will be returned.
243        fallback_for: Vec<AssetFallbackConfig>,
244
245        /// A list of aliases for this asset. If a request is made for one of
246        /// these aliases, the asset will be served as if the request was made
247        /// for the original path.
248        ///
249        /// For example, if an asset is configured with the path `index.html` and
250        /// the alias `/`, a request for `/` will be served the
251        /// asset at `index.html`.
252        aliased_by: Vec<String>,
253
254        /// A list of encodings to serve the asset with. Each listing includes
255        /// the encoding of an asset, and the file extension for the encoded
256        /// asset. The router will search for an asset with the provided file
257        /// extension and certify all matching encoded assets, if found.
258        ///
259        /// A list of alternative encodings that can be used to serve the asset.
260        ///
261        /// Each entry is a tuple of the [encoding name](AssetEncoding) and the
262        /// file extension used in the file path. For example, to include Brotli
263        /// and Gzip encodings:
264        /// `vec![AssetEncoding::Brotli.default_config(), AssetEncoding::Gzip.default_config()]`
265        ///
266        /// Each encoding referenced must be provided to the asset router as a
267        /// separate file with the same filename as the original file, but with
268        /// an additional file extension matching the configuration. For
269        /// example, if the current matched file is named `file.html`, then the
270        /// asset router will look for `file.html.br` and `file.html.gz`.
271        ///
272        /// If the file is found, the asset will be certified and served with
273        /// the provided encoding according to the `Accept-Encoding`. Encodings
274        /// are prioritized in the following order:
275        ///     - Brotli
276        ///     - Zstd
277        ///     - Gzip
278        ///     - Deflate
279        ///     - Identity
280        ///
281        /// The asset router will return the highest priority encoding that has
282        /// been certified and is supported by the client.
283        encodings: Vec<(AssetEncoding, String)>,
284    },
285
286    /// Matches files using a glob pattern.
287    Pattern {
288        /// A glob pattern to match files against.
289        ///
290        /// Standard Unix-style glob syntax is supported:
291        /// - `?` matches any single character.
292        /// - `*` matches zero or more characters.
293        /// - `**` recursively matches directories but is only legal in three
294        ///   situations.
295        ///   - If the glob starts with `**/`, then it matches all directories.
296        ///     For example, `**/foo` matches `foo` and `bar/foo` but not
297        ///     `foo/bar`.
298        ///   - If the glob ends with `/**`, then it matches all sub-entries.
299        ///     For example, `foo/**` matches `foo/a` and `foo/a/b`, but not
300        ///     `foo`.
301        ///   - If the glob contains `/**/` anywhere within the pattern, then it
302        ///     matches zero or more directories.
303        ///   - Using `**` anywhere else is illegal.
304        ///   - The glob `**` is allowed and means "match everything".
305        /// - `{a,b}` matches `a` or `b` where `a` and `b` are arbitrary glob
306        ///   patterns. (N.B. Nesting {...} is not currently allowed.)
307        /// - `[ab]` matches `a` or `b` where `a` and `b` are characters.
308        /// - `[!ab]` to match any character except for `a` and `b`.
309        /// - Metacharacters such as `*` and `?` can be escaped with character
310        ///   class notation. e.g., `[*]` matches `*`.
311        pattern: String,
312
313        /// The content type of the file (e.g. "text/javascript").
314        ///
315        /// Providing this option will auto-insert a `Content-Type` header with
316        /// the provided value. If this value is not provided, the
317        /// `Content-Type` header will not be inserted.
318        ///
319        /// If the `Content-Type` header is not sent to the browser, the browser
320        /// will try to guess the content type based on the file extension,
321        /// unless a `X-Content-Type-Options: nosniff` header is sent.
322        ///
323        /// Not certifying the `Content-Type` header will also allow a malicious
324        /// replica to insert its own `Content-Type` header, which could lead
325        /// to a security vulnerability.
326        content_type: Option<String>,
327
328        /// Additional headers to be inserted into the response. Each additional
329        /// header added will be included in certification and served by the
330        /// [AssetRouter](crate::AssetRouter) for matching [Assets](Asset).
331        headers: Vec<(String, String)>,
332
333        /// A list of encodings to serve the asset with. Each listing includes
334        /// the encoding of an asset, and the file extension for the encoded
335        /// asset. The router will search for an asset with the provided file
336        /// extension and certify all matching encoded assets, if found.
337        ///
338        /// A list of alternative encodings that can be used to serve the asset.
339        ///
340        /// Each entry is a tuple of the [encoding name](AssetEncoding) and the
341        /// file extension used in the file path. For example, to include Brotli
342        /// and Gzip encodings:
343        /// `vec![AssetEncoding::Brotli.default_config(), AssetEncoding::Gzip.default_config()]`
344        ///
345        /// Each encoding referenced must be provided to the asset router as a
346        /// separate file with the same filename as the original file, but with
347        /// an additional file extension matching the configuration. For
348        /// example, if the current matched file is named `file.html`, then the
349        /// asset router will look for `file.html.br` and `file.html.gz`.
350        ///
351        /// If the file is found, the asset will be certified and served with
352        /// the provided encoding according to the `Accept-Encoding`. Encodings
353        /// are prioritized in the following order:
354        ///     - Brotli
355        ///     - Zstd
356        ///     - Gzip
357        ///     - Deflate
358        ///     - Identity
359        ///
360        /// The asset router will return the highest priority encoding that has
361        /// been certified and is supported by the client.
362        encodings: Vec<(AssetEncoding, String)>,
363    },
364
365    /// Redirects the request to another URL. This config type is not matched
366    /// against any assets.
367    Redirect {
368        /// The URL to redirect from.
369        from: String,
370
371        /// The URL to redirect to.
372        to: String,
373
374        /// The kind redirect to configure.
375        kind: AssetRedirectKind,
376
377        /// Additional headers to be inserted into the response. Each additional
378        /// header added will be included in certification and served by the
379        /// [AssetRouter](crate::AssetRouter) for matching [Assets](Asset).
380        ///
381        /// Note that the `Location` header will be automatically added to the
382        /// response with the value of the `to` field.
383        headers: Vec<(String, String)>,
384    },
385}
386
387/// Configuration for an asset to be used as a fallback for a specific scope.
388///
389/// See the [fallback_for](AssetConfig::File::fallback_for) configuration
390/// of the [AssetConfig] interface for more information.
391#[derive(Debug, Clone)]
392pub struct AssetFallbackConfig {
393    /// The scope to use this asset as a fallback for.
394    ///
395    /// See the [fallback_for](AssetConfig::File::fallback_for)
396    /// configuration of the [AssetConfig] interface for more information.
397    pub scope: String,
398
399    /// The HTTP status code to return when serving the asset.
400    /// If this value is not provided, the default status code will be 200.
401    pub status_code: Option<StatusCode>,
402}
403
404/// The type of redirect to use. Redirects can be either
405/// [permanent](AssetRedirectKind::Permanent) or
406/// [temporary](AssetRedirectKind::Temporary).
407#[derive(Debug, Clone)]
408pub enum AssetRedirectKind {
409    /// A permanent redirect (301).
410    ///
411    /// The browser will cache this redirect and will not make a request to the
412    /// old location again. This is useful when the resource has permanently
413    /// moved to a new location. The browser will update its bookmarks and
414    /// search engine results.
415    ///
416    /// See the
417    /// [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/301)
418    /// for more information.
419    Permanent,
420
421    /// A temporary redirect (307).
422    ///
423    /// The browser will not cache this redirect and will make a request to the
424    /// old location again. This is useful when the resource has temporarily
425    /// moved to a new location. The browser will not update its bookmarks and
426    /// search engine results.
427    ///
428    /// See the
429    /// [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/307)
430    /// for more information.
431    Temporary,
432}
433
434/// The encoding of an asset.
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
436pub enum AssetEncoding {
437    /// The asset is not encoded.
438    Identity,
439
440    /// The asset is encoded with the Brotli algorithm.
441    Brotli,
442
443    /// The asset is encoded with the Zstd algorithm.
444    Zstd,
445
446    /// The asset is encoded with the Gzip algorithm.
447    Gzip,
448
449    /// The asset is encoded with the Deflate algorithm.
450    Deflate,
451}
452
453impl AssetEncoding {
454    /// Returns the default encoding and file extension for the encoding.
455    /// The default encoding is the encoding that is used when the client
456    /// does not specify an encoding in the `Accept-Encoding` header.
457    ///
458    /// The default encoding and file extension are:
459    /// - [Brotli](AssetEncoding::Brotli): `br`
460    /// - [Zstd](AssetEncoding::Zstd): `zst`
461    /// - [Gzip](AssetEncoding::Gzip): `gz`
462    /// - [Deflate](AssetEncoding::Deflate): `zz`
463    ///
464    /// # Examples
465    ///
466    /// ```
467    /// use ic_asset_certification::AssetEncoding;
468    ///
469    /// let (encoding, extension) = AssetEncoding::Brotli.default_config();
470    /// assert_eq!(encoding, AssetEncoding::Brotli);
471    /// assert_eq!(extension, ".br");
472    ///
473    /// let (encoding, extension) = AssetEncoding::Zstd.default_config();
474    /// assert_eq!(encoding, AssetEncoding::Zstd);
475    /// assert_eq!(extension, ".zst");
476    ///
477    /// let (encoding, extension) = AssetEncoding::Gzip.default_config();
478    /// assert_eq!(encoding, AssetEncoding::Gzip);
479    /// assert_eq!(extension, ".gz");
480    ///
481    /// let (encoding, extension) = AssetEncoding::Deflate.default_config();
482    /// assert_eq!(encoding, AssetEncoding::Deflate);
483    /// assert_eq!(extension, ".zz");
484    ///
485    /// let (encoding, extension) = AssetEncoding::Identity.default_config();
486    /// assert_eq!(encoding, AssetEncoding::Identity);
487    /// assert_eq!(extension, "");
488    /// ```
489    pub fn default_config(self) -> (AssetEncoding, String) {
490        let file_extension = match self {
491            AssetEncoding::Identity => "".to_string(),
492            AssetEncoding::Brotli => ".br".to_string(),
493            AssetEncoding::Zstd => ".zst".to_string(),
494            AssetEncoding::Gzip => ".gz".to_string(),
495            AssetEncoding::Deflate => ".zz".to_string(),
496        };
497
498        (self, file_extension)
499    }
500
501    /// Returns an encoding with a custom file extension. This is useful
502    /// when the default file extension assigned by [default_config](AssetEncoding::default_config)
503    /// is not desired.
504    ///
505    /// # Examples
506    ///
507    /// ```
508    /// use ic_asset_certification::AssetEncoding;
509    ///
510    /// let (encoding, extension) = AssetEncoding::Brotli.custom_config("brotli".to_string());
511    ///
512    /// assert_eq!(encoding, AssetEncoding::Brotli);
513    /// assert_eq!(extension, "brotli");
514    /// ```
515    pub fn custom_config(self, extension: String) -> (AssetEncoding, String) {
516        (self, extension)
517    }
518}
519
520impl Display for AssetEncoding {
521    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
522        let str = match self {
523            AssetEncoding::Identity => "identity".to_string(),
524            AssetEncoding::Brotli => "br".to_string(),
525            AssetEncoding::Zstd => "zstd".to_string(),
526            AssetEncoding::Gzip => "gzip".to_string(),
527            AssetEncoding::Deflate => "deflate".to_string(),
528        };
529
530        write!(f, "{str}")
531    }
532}
533
534#[derive(Debug, Clone)]
535pub(crate) enum NormalizedAssetConfig {
536    File {
537        path: String,
538        content_type: Option<String>,
539        headers: Vec<(String, String)>,
540        fallback_for: Vec<AssetFallbackConfig>,
541        aliased_by: Vec<String>,
542        encodings: Vec<(AssetEncoding, String)>,
543    },
544    Pattern {
545        pattern: GlobMatcher,
546        content_type: Option<String>,
547        headers: Vec<(String, String)>,
548        encodings: Vec<(AssetEncoding, String)>,
549    },
550    Redirect {
551        from: String,
552        to: String,
553        kind: AssetRedirectKind,
554        headers: Vec<(String, String)>,
555    },
556}
557
558impl TryFrom<AssetConfig> for NormalizedAssetConfig {
559    type Error = AssetCertificationError;
560
561    fn try_from(config: AssetConfig) -> Result<Self, Self::Error> {
562        match config {
563            AssetConfig::File {
564                path,
565                content_type,
566                headers,
567                fallback_for,
568                aliased_by,
569                encodings,
570            } => Ok(NormalizedAssetConfig::File {
571                path,
572                content_type,
573                headers,
574                fallback_for,
575                aliased_by,
576                encodings,
577            }),
578            AssetConfig::Pattern {
579                pattern,
580                content_type,
581                headers,
582                encodings,
583            } => Ok(NormalizedAssetConfig::Pattern {
584                pattern: Glob::new(&pattern)?.compile_matcher(),
585                content_type,
586                headers,
587                encodings,
588            }),
589            AssetConfig::Redirect {
590                from,
591                to,
592                kind,
593                headers,
594            } => Ok(NormalizedAssetConfig::Redirect {
595                from,
596                to,
597                kind,
598                headers,
599            }),
600        }
601    }
602}
603
604impl NormalizedAssetConfig {
605    pub(crate) fn matches_asset(&self, asset: &Asset) -> bool {
606        match self {
607            Self::File { path, .. } => path == asset.path.as_ref(),
608            Self::Pattern { pattern, .. } => pattern.is_match(asset.path.as_ref()),
609            Self::Redirect { .. } => false,
610        }
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::Asset;
618    use rstest::*;
619
620    #[rstest]
621    #[case("index.html", "index.html", true)]
622    #[case("app.js", "app.js", true)]
623    #[case("index.js", "app.css", false)]
624    #[case("index.css", "index.js", false)]
625    fn matches_asset_file(
626        #[case] asset_path: &str,
627        #[case] config_path: &str,
628        #[case] expected: bool,
629    ) {
630        let asset = Asset::new(asset_path, vec![]);
631        let config: NormalizedAssetConfig = AssetConfig::File {
632            path: config_path.to_string(),
633            content_type: None,
634            headers: vec![],
635            fallback_for: vec![],
636            aliased_by: vec![],
637            encodings: vec![],
638        }
639        .try_into()
640        .unwrap();
641
642        assert_eq!(config.matches_asset(&asset), expected);
643    }
644
645    #[rstest]
646    // index.html, *
647    #[case("index.html", "*", true)]
648    #[case("index.html", "**", true)]
649    #[case("index.html", "**/*", true)]
650    #[case("index.html", "**/**", true)]
651    // app.js, *
652    #[case("app.js", "*", true)]
653    #[case("app.js", "**", true)]
654    #[case("app.js", "**/*", true)]
655    #[case("app.js", "**/**", true)]
656    // index.html, *.html
657    #[case("index.html", "*.html", true)]
658    #[case("index.html", "**.html", true)]
659    #[case("index.html", "**/*.html", true)]
660    #[case("index.html", "**/**.html", true)]
661    // app.js, *.html
662    #[case("app.js", "*.html", false)]
663    #[case("app.js", "**.html", false)]
664    #[case("app.js", "**/*.html", false)]
665    #[case("app.js", "**/**.html", false)]
666    // app.js, *.js
667    #[case("app.js", "*.js", true)]
668    #[case("app.js", "**.js", true)]
669    #[case("app.js", "**/*.js", true)]
670    #[case("app.js", "**/**.js", true)]
671    // index.html, *.{js,html}
672    #[case("index.html", "*.{js,html}", true)]
673    #[case("index.html", "**.{js,html}", true)]
674    #[case("index.html", "**/*.{js,html}", true)]
675    #[case("index.html", "**/**.{js,html}", true)]
676    // app.js, *.{js,html}
677    #[case("app.js", "*.{js,html}", true)]
678    #[case("app.js", "**.{js,html}", true)]
679    #[case("app.js", "**/*.{js,html}", true)]
680    #[case("app.js", "**/**.{js,html}", true)]
681    // index.html, assets/*.html
682    #[case("index.html", "assets/*.html", false)]
683    #[case("index.html", "assets/**.html", false)]
684    #[case("index.html", "assets/**/*.html", false)]
685    // app.js, assets/*.js
686    #[case("app.js", "assets/*.js", false)]
687    #[case("app.js", "assets/**.js", false)]
688    #[case("app.js", "assets/**/*.js", false)]
689    // assets/index.html, *
690    #[case("assets/index.html", "*", true)]
691    #[case("assets/index.html", "**", true)]
692    #[case("assets/index.html", "**/*", true)]
693    #[case("assets/index.html", "**/**", true)]
694    // assets/app.js, *
695    #[case("assets/app.js", "*", true)]
696    #[case("assets/app.js", "**", true)]
697    #[case("assets/app.js", "**/*", true)]
698    #[case("assets/app.js", "**/**", true)]
699    // assets/index.html, *.html
700    #[case("assets/index.html", "*.html", true)]
701    #[case("assets/index.html", "**.html", true)]
702    #[case("assets/index.html", "**/*.html", true)]
703    // assets/app.js, *.js
704    #[case("assets/app.js", "*.js", true)]
705    #[case("assets/app.js", "**.js", true)]
706    #[case("assets/app.js", "**/*.js", true)]
707    // assets/index.html, assets/*.html
708    #[case("assets/index.html", "assets/*.html", true)]
709    #[case("assets/index.html", "assets/**.html", true)]
710    #[case("assets/index.html", "assets/**/*.html", true)]
711    // assets/app.js, assets/*.js
712    #[case("assets/app.js", "assets/*.js", true)]
713    #[case("assets/app.js", "assets/**.js", true)]
714    #[case("assets/app.js", "assets/**/*.js", true)]
715    // assets/index.html, assets/*.{js,html}
716    #[case("assets/index.html", "assets/*.{js,html}", true)]
717    #[case("assets/index.html", "assets/**.{js,html}", true)]
718    #[case("assets/index.html", "assets/**/*.{js,html}", true)]
719    #[case("assets/index.html", "assets/**/**.{js,html}", true)]
720    // assets/app.js, assets/*.{js,html}
721    #[case("assets/app.js", "assets/*.{js,html}", true)]
722    #[case("assets/app.js", "assets/**.{js,html}", true)]
723    #[case("assets/app.js", "assets/**/*.{js,html}", true)]
724    #[case("assets/app.js", "assets/**/**.{js,html}", true)]
725    fn matches_asset_pattern(
726        #[case] asset_path: &str,
727        #[case] config_pattern: &str,
728        #[case] expected: bool,
729    ) {
730        let asset = Asset::new(asset_path, vec![]);
731        let config: NormalizedAssetConfig = AssetConfig::Pattern {
732            pattern: config_pattern.to_string(),
733            content_type: None,
734            headers: vec![],
735            encodings: vec![],
736        }
737        .try_into()
738        .unwrap();
739
740        assert_eq!(config.matches_asset(&asset), expected);
741    }
742
743    #[rstest]
744    #[case("index.html")]
745    #[case("app.js")]
746    #[case("index.js")]
747    #[case("index.css")]
748    fn does_not_match_asset_redirect(#[case] asset_path: &str) {
749        let asset = Asset::new(asset_path, vec![]);
750        let config: NormalizedAssetConfig = AssetConfig::Redirect {
751            from: asset_path.to_string(),
752            to: asset_path.to_string(),
753            kind: AssetRedirectKind::Permanent,
754            headers: vec![(
755                "content-type".to_string(),
756                "text/plain; charset=utf-8".to_string(),
757            )],
758        }
759        .try_into()
760        .unwrap();
761
762        assert!(!config.matches_asset(&asset));
763    }
764
765    #[rstest]
766    fn asset_encoding_to_string() {
767        assert_eq!(AssetEncoding::Brotli.to_string(), "br");
768        assert_eq!(AssetEncoding::Zstd.to_string(), "zstd");
769        assert_eq!(AssetEncoding::Gzip.to_string(), "gzip");
770        assert_eq!(AssetEncoding::Deflate.to_string(), "deflate");
771        assert_eq!(AssetEncoding::Identity.to_string(), "identity");
772    }
773}