embedded_assets.rs 14 KB

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