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).map_err(|error| EmbeddedAssetsError::AssetRead {
184            path: path.to_path_buf(),
185            error,
186          })?,
187        );
188        let hash = hasher.finalize();
189        self.scripts.push(format!(
190          "'sha256-{}'",
191          base64::engine::general_purpose::STANDARD.encode(hash)
192        ));
193      }
194    }
195
196    Ok(())
197  }
198}
199
200/// Options used to embed assets.
201#[derive(Default)]
202pub struct AssetOptions {
203  pub(crate) csp: bool,
204  pub(crate) pattern: PatternKind,
205  pub(crate) freeze_prototype: bool,
206  pub(crate) dangerous_disable_asset_csp_modification: DisabledCspModificationKind,
207  #[cfg(feature = "isolation")]
208  pub(crate) isolation_schema: String,
209}
210
211impl AssetOptions {
212  /// Creates the default asset options.
213  pub fn new(pattern: PatternKind) -> Self {
214    Self {
215      csp: false,
216      pattern,
217      freeze_prototype: false,
218      dangerous_disable_asset_csp_modification: DisabledCspModificationKind::Flag(false),
219      #[cfg(feature = "isolation")]
220      isolation_schema: format!("isolation-{}", uuid::Uuid::new_v4()),
221    }
222  }
223
224  /// Instruct the asset handler to inject the CSP token to HTML files (Linux only) and add asset nonces and hashes to the policy.
225  #[must_use]
226  pub fn with_csp(mut self) -> Self {
227    self.csp = true;
228    self
229  }
230
231  /// Instruct the asset handler to include a script to freeze the `Object.prototype` on all HTML files.
232  #[must_use]
233  pub fn freeze_prototype(mut self, freeze: bool) -> Self {
234    self.freeze_prototype = freeze;
235    self
236  }
237
238  /// Instruct the asset handler to **NOT** modify the CSP. This is **NOT** recommended.
239  pub fn dangerous_disable_asset_csp_modification(
240    mut self,
241    dangerous_disable_asset_csp_modification: DisabledCspModificationKind,
242  ) -> Self {
243    self.dangerous_disable_asset_csp_modification = dangerous_disable_asset_csp_modification;
244    self
245  }
246}
247
248impl EmbeddedAssets {
249  /// Compress a collection of files and directories, ready to be generated into [`Assets`].
250  ///
251  /// [`Assets`]: tauri_utils::assets::Assets
252  pub fn new(
253    input: impl Into<EmbeddedAssetsInput>,
254    options: &AssetOptions,
255    mut map: impl FnMut(
256      &AssetKey,
257      &Path,
258      &mut Vec<u8>,
259      &mut CspHashes,
260    ) -> Result<(), EmbeddedAssetsError>,
261  ) -> Result<Self, EmbeddedAssetsError> {
262    // we need to pre-compute all files now, so that we can inject data from all files into a few
263    let RawEmbeddedAssets { paths, csp_hashes } = RawEmbeddedAssets::new(input.into(), options)?;
264
265    struct CompressState {
266      csp_hashes: CspHashes,
267      assets: HashMap<AssetKey, (PathBuf, PathBuf)>,
268    }
269
270    let CompressState { assets, csp_hashes } = paths.into_iter().try_fold(
271      CompressState {
272        csp_hashes,
273        assets: HashMap::new(),
274      },
275      move |mut state, (prefix, entry)| {
276        let (key, asset) =
277          Self::compress_file(&prefix, entry.path(), &mut map, &mut state.csp_hashes)?;
278        state.assets.insert(key, asset);
279        Result::<_, EmbeddedAssetsError>::Ok(state)
280      },
281    )?;
282
283    Ok(Self { assets, csp_hashes })
284  }
285
286  /// Use highest compression level for release, the fastest one for everything else
287  #[cfg(feature = "compression")]
288  fn compression_settings() -> BrotliEncoderParams {
289    let mut settings = BrotliEncoderParams::default();
290
291    // the following compression levels are hand-picked and are not min-maxed.
292    // they have a good balance of runtime vs size for the respective profile goals.
293    // see the "brotli" section of this comment https://github.com/tauri-apps/tauri/issues/3571#issuecomment-1054847558
294    if cfg!(debug_assertions) {
295      settings.quality = 2
296    } else {
297      settings.quality = 9
298    }
299
300    settings
301  }
302
303  /// Compress a file and spit out the information in a [`HashMap`] friendly form.
304  fn compress_file(
305    prefix: &Path,
306    path: &Path,
307    map: &mut impl FnMut(
308      &AssetKey,
309      &Path,
310      &mut Vec<u8>,
311      &mut CspHashes,
312    ) -> Result<(), EmbeddedAssetsError>,
313    csp_hashes: &mut CspHashes,
314  ) -> Result<Asset, EmbeddedAssetsError> {
315    let mut input = std::fs::read(path).map_err(|error| EmbeddedAssetsError::AssetRead {
316      path: path.to_owned(),
317      error,
318    })?;
319
320    // get a key to the asset path without the asset directory prefix
321    let key = path
322      .strip_prefix(prefix)
323      .map(AssetKey::from) // format the path for use in assets
324      .map_err(|_| EmbeddedAssetsError::PrefixInvalid {
325        prefix: prefix.to_owned(),
326        path: path.to_owned(),
327      })?;
328
329    // perform any caller-requested input manipulation
330    map(&key, path, &mut input, csp_hashes)?;
331
332    // we must canonicalize the base of our paths to allow long paths on windows
333    let out_dir = std::env::var("OUT_DIR")
334      .map_err(|_| EmbeddedAssetsError::OutDir)
335      .map(PathBuf::from)
336      .and_then(|p| p.canonicalize().map_err(|_| EmbeddedAssetsError::OutDir))
337      .map(|p| p.join(TARGET_PATH))?;
338
339    // make sure that our output directory is created
340    std::fs::create_dir_all(&out_dir).map_err(|_| EmbeddedAssetsError::OutDir)?;
341
342    // get a hash of the input - allows for caching existing files
343    let hash = crate::checksum(&input).map_err(EmbeddedAssetsError::Hex)?;
344
345    // use the content hash to determine filename, keep extensions that exist
346    let out_path = if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
347      out_dir.join(format!("{hash}.{ext}"))
348    } else {
349      out_dir.join(hash)
350    };
351
352    // only compress and write to the file if it doesn't already exist.
353    if !out_path.exists() {
354      #[allow(unused_mut)]
355      let mut out_file =
356        File::create(&out_path).map_err(|error| EmbeddedAssetsError::AssetWrite {
357          path: out_path.clone(),
358          error,
359        })?;
360
361      #[cfg(not(feature = "compression"))]
362      {
363        use std::io::Write;
364        out_file
365          .write_all(&input)
366          .map_err(|error| EmbeddedAssetsError::AssetWrite {
367            path: path.to_owned(),
368            error,
369          })?;
370      }
371
372      #[cfg(feature = "compression")]
373      {
374        let mut input = std::io::Cursor::new(input);
375        // entirely write input to the output file path with compression
376        brotli::BrotliCompress(&mut input, &mut out_file, &Self::compression_settings()).map_err(
377          |error| EmbeddedAssetsError::AssetWrite {
378            path: path.to_owned(),
379            error,
380          },
381        )?;
382      }
383    }
384
385    Ok((key, (path.into(), out_path)))
386  }
387}
388
389impl ToTokens for EmbeddedAssets {
390  fn to_tokens(&self, tokens: &mut TokenStream) {
391    let mut assets = TokenStream::new();
392    for (key, (input, output)) in &self.assets {
393      let key: &str = key.as_ref();
394      let input = input.display().to_string();
395      let output = output.display().to_string();
396
397      // add original asset as a compiler dependency, rely on dead code elimination to clean it up
398      assets.append_all(quote!(#key => {
399        const _: &[u8] = include_bytes!(#input);
400        include_bytes!(#output)
401      },));
402    }
403
404    let mut global_hashes = TokenStream::new();
405    for script_hash in &self.csp_hashes.scripts {
406      let hash = script_hash.as_str();
407      global_hashes.append_all(quote!(CspHash::Script(#hash),));
408    }
409
410    for style_hash in &self.csp_hashes.styles {
411      let hash = style_hash.as_str();
412      global_hashes.append_all(quote!(CspHash::Style(#hash),));
413    }
414
415    let mut html_hashes = TokenStream::new();
416    for (path, hashes) in &self.csp_hashes.inline_scripts {
417      let key = path.as_str();
418      let mut value = TokenStream::new();
419      for script_hash in hashes {
420        let hash = script_hash.as_str();
421        value.append_all(quote!(CspHash::Script(#hash),));
422      }
423      html_hashes.append_all(quote!(#key => &[#value],));
424    }
425
426    // we expect phf related items to be in path when generating the path code
427    tokens.append_all(quote! {{
428        #[allow(unused_imports)]
429        use ::tauri::utils::assets::{CspHash, EmbeddedAssets, phf, phf::phf_map};
430        EmbeddedAssets::new(phf_map! { #assets }, &[#global_hashes], phf_map! { #html_hashes })
431    }});
432  }
433}
434
435pub(crate) fn ensure_out_dir() -> EmbeddedAssetsResult<PathBuf> {
436  let out_dir = std::env::var("OUT_DIR")
437    .map_err(|_| EmbeddedAssetsError::OutDir)
438    .map(PathBuf::from)
439    .and_then(|p| p.canonicalize().map_err(|_| EmbeddedAssetsError::OutDir))?;
440
441  // make sure that our output directory is created
442  std::fs::create_dir_all(&out_dir).map_err(|_| EmbeddedAssetsError::OutDir)?;
443  Ok(out_dir)
444}