tauri_codegen/
embedded_assets.rs

1// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5use base64::Engine;
6use proc_macro2::TokenStream;
7use quote::{quote, ToTokens, TokenStreamExt};
8use sha2::{Digest, Sha256};
9use std::{
10  collections::HashMap,
11  fs::File,
12  path::{Path, PathBuf},
13};
14use tauri_utils::config::PatternKind;
15use tauri_utils::{assets::AssetKey, config::DisabledCspModificationKind};
16use thiserror::Error;
17use walkdir::{DirEntry, WalkDir};
18
19#[cfg(feature = "compression")]
20use brotli::enc::backward_references::BrotliEncoderParams;
21
22/// The subdirectory inside the target directory we want to place assets.
23const TARGET_PATH: &str = "tauri-codegen-assets";
24
25/// (key, (original filepath, compressed bytes))
26type Asset = (AssetKey, (PathBuf, PathBuf));
27
28/// All possible errors while reading and compressing an [`EmbeddedAssets`] directory
29#[derive(Debug, Error)]
30#[non_exhaustive]
31pub enum EmbeddedAssetsError {
32  #[error("failed to read asset at {path} because {error}")]
33  AssetRead {
34    path: PathBuf,
35    error: std::io::Error,
36  },
37
38  #[error("failed to write asset from {path} to Vec<u8> because {error}")]
39  AssetWrite {
40    path: PathBuf,
41    error: std::io::Error,
42  },
43
44  #[error("failed to create hex from bytes because {0}")]
45  Hex(std::fmt::Error),
46
47  #[error("invalid prefix {prefix} used while including path {path}")]
48  PrefixInvalid { prefix: PathBuf, path: PathBuf },
49
50  #[error("invalid extension `{extension}` used for image {path}, must be `ico` or `png`")]
51  InvalidImageExtension { extension: PathBuf, path: PathBuf },
52
53  #[error("failed to walk directory {path} because {error}")]
54  Walkdir {
55    path: PathBuf,
56    error: walkdir::Error,
57  },
58
59  #[error("OUT_DIR env var is not set, do you have a build script?")]
60  OutDir,
61
62  #[error("version error: {0}")]
63  Version(#[from] semver::Error),
64}
65
66pub type EmbeddedAssetsResult<T> = Result<T, EmbeddedAssetsError>;
67
68/// Represent a directory of assets that are compressed and embedded.
69///
70/// This is the compile time generation of [`tauri_utils::assets::Assets`] from a directory. Assets
71/// from the directory are added as compiler dependencies by dummy including the original,
72/// uncompressed assets.
73///
74/// The assets are compressed during this runtime, and can only be represented as a [`TokenStream`]
75/// through [`ToTokens`]. The generated code is meant to be injected into an application to include
76/// the compressed assets in that application's binary.
77#[derive(Default)]
78pub struct EmbeddedAssets {
79  assets: HashMap<AssetKey, (PathBuf, PathBuf)>,
80  csp_hashes: CspHashes,
81}
82
83pub struct EmbeddedAssetsInput(Vec<PathBuf>);
84
85impl From<PathBuf> for EmbeddedAssetsInput {
86  fn from(path: PathBuf) -> Self {
87    Self(vec![path])
88  }
89}
90
91impl From<Vec<PathBuf>> for EmbeddedAssetsInput {
92  fn from(paths: Vec<PathBuf>) -> Self {
93    Self(paths)
94  }
95}
96
97/// Holds a list of (prefix, entry)
98struct RawEmbeddedAssets {
99  paths: Vec<(PathBuf, DirEntry)>,
100  csp_hashes: CspHashes,
101}
102
103impl RawEmbeddedAssets {
104  /// Creates a new list of (prefix, entry) from a collection of inputs.
105  fn new(input: EmbeddedAssetsInput, options: &AssetOptions) -> Result<Self, EmbeddedAssetsError> {
106    let mut csp_hashes = CspHashes::default();
107
108    input
109      .0
110      .into_iter()
111      .flat_map(|path| {
112        let prefix = if path.is_dir() {
113          path.clone()
114        } else {
115          path
116            .parent()
117            .expect("embedded file asset has no parent")
118            .to_path_buf()
119        };
120
121        WalkDir::new(&path)
122          .follow_links(true)
123          .contents_first(true)
124          .into_iter()
125          .map(move |entry| (prefix.clone(), entry))
126      })
127      .filter_map(|(prefix, entry)| {
128        match entry {
129          // we only serve files, not directory listings
130          Ok(entry) if entry.file_type().is_dir() => None,
131
132          // compress all files encountered
133          Ok(entry) => {
134            if let Err(error) = csp_hashes
135              .add_if_applicable(&entry, &options.dangerous_disable_asset_csp_modification)
136            {
137              Some(Err(error))
138            } else {
139              Some(Ok((prefix, entry)))
140            }
141          }
142
143          // pass down error through filter to fail when encountering any error
144          Err(error) => Some(Err(EmbeddedAssetsError::Walkdir {
145            path: prefix,
146            error,
147          })),
148        }
149      })
150      .collect::<Result<Vec<(PathBuf, DirEntry)>, _>>()
151      .map(|paths| Self { paths, csp_hashes })
152  }
153}
154
155/// Holds all hashes that we will apply on the CSP tag/header.
156#[derive(Debug, Default)]
157pub struct CspHashes {
158  /// Scripts that are part of the asset collection (JS or MJS files).
159  pub(crate) scripts: Vec<String>,
160  /// Inline scripts (`<script>code</script>`). Maps a HTML path to a list of hashes.
161  pub(crate) inline_scripts: HashMap<String, Vec<String>>,
162  /// A list of hashes of the contents of all `style` elements.
163  pub(crate) styles: Vec<String>,
164}
165
166impl CspHashes {
167  /// Only add a CSP hash to the appropriate category if we think the file matches
168  ///
169  /// Note: this only checks the file extension, much like how a browser will assume a .js file is
170  /// a JavaScript file unless HTTP headers tell it otherwise.
171  pub fn add_if_applicable(
172    &mut self,
173    entry: &DirEntry,
174    dangerous_disable_asset_csp_modification: &DisabledCspModificationKind,
175  ) -> Result<(), EmbeddedAssetsError> {
176    let path = entry.path();
177
178    // we only hash JavaScript files for now, may expand to other CSP hashable types in the future
179    if let Some("js") | Some("mjs") = path.extension().and_then(|os| os.to_str()) {
180      if dangerous_disable_asset_csp_modification.can_modify("script-src") {
181        let mut hasher = Sha256::new();
182        hasher.update(
183          &std::fs::read(path)
184            .map(|b| tauri_utils::html::normalize_script_for_csp(&b))
185            .map_err(|error| EmbeddedAssetsError::AssetRead {
186              path: path.to_path_buf(),
187              error,
188            })?,
189        );
190        let hash = hasher.finalize();
191        self.scripts.push(format!(
192          "'sha256-{}'",
193          base64::engine::general_purpose::STANDARD.encode(hash)
194        ));
195      }
196    }
197
198    Ok(())
199  }
200}
201
202/// Options used to embed assets.
203#[derive(Default)]
204pub struct AssetOptions {
205  pub(crate) csp: bool,
206  pub(crate) pattern: PatternKind,
207  pub(crate) freeze_prototype: bool,
208  pub(crate) dangerous_disable_asset_csp_modification: DisabledCspModificationKind,
209  #[cfg(feature = "isolation")]
210  pub(crate) isolation_schema: String,
211}
212
213impl AssetOptions {
214  /// Creates the default asset options.
215  pub fn new(pattern: PatternKind) -> Self {
216    Self {
217      csp: false,
218      pattern,
219      freeze_prototype: false,
220      dangerous_disable_asset_csp_modification: DisabledCspModificationKind::Flag(false),
221      #[cfg(feature = "isolation")]
222      isolation_schema: format!("isolation-{}", uuid::Uuid::new_v4()),
223    }
224  }
225
226  /// Instruct the asset handler to inject the CSP token to HTML files (Linux only) and add asset nonces and hashes to the policy.
227  #[must_use]
228  pub fn with_csp(mut self) -> Self {
229    self.csp = true;
230    self
231  }
232
233  /// Instruct the asset handler to include a script to freeze the `Object.prototype` on all HTML files.
234  #[must_use]
235  pub fn freeze_prototype(mut self, freeze: bool) -> Self {
236    self.freeze_prototype = freeze;
237    self
238  }
239
240  /// Instruct the asset handler to **NOT** modify the CSP. This is **NOT** recommended.
241  pub fn dangerous_disable_asset_csp_modification(
242    mut self,
243    dangerous_disable_asset_csp_modification: DisabledCspModificationKind,
244  ) -> Self {
245    self.dangerous_disable_asset_csp_modification = dangerous_disable_asset_csp_modification;
246    self
247  }
248}
249
250impl EmbeddedAssets {
251  /// Compress a collection of files and directories, ready to be generated into [`Assets`].
252  ///
253  /// [`Assets`]: tauri_utils::assets::Assets
254  pub fn new(
255    input: impl Into<EmbeddedAssetsInput>,
256    options: &AssetOptions,
257    mut map: impl FnMut(
258      &AssetKey,
259      &Path,
260      &mut Vec<u8>,
261      &mut CspHashes,
262    ) -> Result<(), EmbeddedAssetsError>,
263  ) -> Result<Self, EmbeddedAssetsError> {
264    // we need to pre-compute all files now, so that we can inject data from all files into a few
265    let RawEmbeddedAssets { paths, csp_hashes } = RawEmbeddedAssets::new(input.into(), options)?;
266
267    struct CompressState {
268      csp_hashes: CspHashes,
269      assets: HashMap<AssetKey, (PathBuf, PathBuf)>,
270    }
271
272    let CompressState { assets, csp_hashes } = paths.into_iter().try_fold(
273      CompressState {
274        csp_hashes,
275        assets: HashMap::new(),
276      },
277      move |mut state, (prefix, entry)| {
278        let (key, asset) =
279          Self::compress_file(&prefix, entry.path(), &mut map, &mut state.csp_hashes)?;
280        state.assets.insert(key, asset);
281        Result::<_, EmbeddedAssetsError>::Ok(state)
282      },
283    )?;
284
285    Ok(Self { assets, csp_hashes })
286  }
287
288  /// Use highest compression level for release, the fastest one for everything else
289  #[cfg(feature = "compression")]
290  fn compression_settings() -> BrotliEncoderParams {
291    let mut settings = BrotliEncoderParams::default();
292
293    // the following compression levels are hand-picked and are not min-maxed.
294    // they have a good balance of runtime vs size for the respective profile goals.
295    // see the "brotli" section of this comment https://github.com/tauri-apps/tauri/issues/3571#issuecomment-1054847558
296    if cfg!(debug_assertions) {
297      settings.quality = 2
298    } else {
299      settings.quality = 9
300    }
301
302    settings
303  }
304
305  /// Compress a file and spit out the information in a [`HashMap`] friendly form.
306  fn compress_file(
307    prefix: &Path,
308    path: &Path,
309    map: &mut impl FnMut(
310      &AssetKey,
311      &Path,
312      &mut Vec<u8>,
313      &mut CspHashes,
314    ) -> Result<(), EmbeddedAssetsError>,
315    csp_hashes: &mut CspHashes,
316  ) -> Result<Asset, EmbeddedAssetsError> {
317    let mut input = std::fs::read(path).map_err(|error| EmbeddedAssetsError::AssetRead {
318      path: path.to_owned(),
319      error,
320    })?;
321
322    // get a key to the asset path without the asset directory prefix
323    let key = path
324      .strip_prefix(prefix)
325      .map(AssetKey::from) // format the path for use in assets
326      .map_err(|_| EmbeddedAssetsError::PrefixInvalid {
327        prefix: prefix.to_owned(),
328        path: path.to_owned(),
329      })?;
330
331    // perform any caller-requested input manipulation
332    map(&key, path, &mut input, csp_hashes)?;
333
334    // we must canonicalize the base of our paths to allow long paths on windows
335    let out_dir = std::env::var("OUT_DIR")
336      .map_err(|_| EmbeddedAssetsError::OutDir)
337      .map(PathBuf::from)
338      .and_then(|p| p.canonicalize().map_err(|_| EmbeddedAssetsError::OutDir))
339      .map(|p| p.join(TARGET_PATH))?;
340
341    // make sure that our output directory is created
342    std::fs::create_dir_all(&out_dir).map_err(|_| EmbeddedAssetsError::OutDir)?;
343
344    // get a hash of the input - allows for caching existing files
345    let hash = crate::checksum(&input).map_err(EmbeddedAssetsError::Hex)?;
346
347    // use the content hash to determine filename, keep extensions that exist
348    let out_path = if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
349      out_dir.join(format!("{hash}.{ext}"))
350    } else {
351      out_dir.join(hash)
352    };
353
354    // only compress and write to the file if it doesn't already exist.
355    if !out_path.exists() {
356      #[allow(unused_mut)]
357      let mut out_file =
358        File::create(&out_path).map_err(|error| EmbeddedAssetsError::AssetWrite {
359          path: out_path.clone(),
360          error,
361        })?;
362
363      #[cfg(not(feature = "compression"))]
364      {
365        use std::io::Write;
366        out_file
367          .write_all(&input)
368          .map_err(|error| EmbeddedAssetsError::AssetWrite {
369            path: path.to_owned(),
370            error,
371          })?;
372      }
373
374      #[cfg(feature = "compression")]
375      {
376        let mut input = std::io::Cursor::new(input);
377        // entirely write input to the output file path with compression
378        brotli::BrotliCompress(&mut input, &mut out_file, &Self::compression_settings()).map_err(
379          |error| EmbeddedAssetsError::AssetWrite {
380            path: path.to_owned(),
381            error,
382          },
383        )?;
384      }
385    }
386
387    Ok((key, (path.into(), out_path)))
388  }
389}
390
391impl ToTokens for EmbeddedAssets {
392  fn to_tokens(&self, tokens: &mut TokenStream) {
393    let mut assets = TokenStream::new();
394    for (key, (input, output)) in &self.assets {
395      let key: &str = key.as_ref();
396      let input = input.display().to_string();
397      let output = output.display().to_string();
398
399      // add original asset as a compiler dependency, rely on dead code elimination to clean it up
400      assets.append_all(quote!(#key => {
401        const _: &[u8] = include_bytes!(#input);
402        include_bytes!(#output)
403      },));
404    }
405
406    let mut global_hashes = TokenStream::new();
407    for script_hash in &self.csp_hashes.scripts {
408      let hash = script_hash.as_str();
409      global_hashes.append_all(quote!(CspHash::Script(#hash),));
410    }
411
412    for style_hash in &self.csp_hashes.styles {
413      let hash = style_hash.as_str();
414      global_hashes.append_all(quote!(CspHash::Style(#hash),));
415    }
416
417    let mut html_hashes = TokenStream::new();
418    for (path, hashes) in &self.csp_hashes.inline_scripts {
419      let key = path.as_str();
420      let mut value = TokenStream::new();
421      for script_hash in hashes {
422        let hash = script_hash.as_str();
423        value.append_all(quote!(CspHash::Script(#hash),));
424      }
425      html_hashes.append_all(quote!(#key => &[#value],));
426    }
427
428    // we expect phf related items to be in path when generating the path code
429    tokens.append_all(quote! {{
430        #[allow(unused_imports)]
431        use ::tauri::utils::assets::{CspHash, EmbeddedAssets, phf, phf::phf_map};
432        EmbeddedAssets::new(phf_map! { #assets }, &[#global_hashes], phf_map! { #html_hashes })
433    }});
434  }
435}
436
437pub(crate) fn ensure_out_dir() -> EmbeddedAssetsResult<PathBuf> {
438  let out_dir = std::env::var("OUT_DIR")
439    .map_err(|_| EmbeddedAssetsError::OutDir)
440    .map(PathBuf::from)
441    .and_then(|p| p.canonicalize().map_err(|_| EmbeddedAssetsError::OutDir))?;
442
443  // make sure that our output directory is created
444  std::fs::create_dir_all(&out_dir).map_err(|_| EmbeddedAssetsError::OutDir)?;
445  Ok(out_dir)
446}