Skip to content

Commit

Permalink
feat: add integrity attribute for wasm and loader script
Browse files Browse the repository at this point in the history
  • Loading branch information
ctron committed Nov 6, 2023
1 parent ed320fb commit 32fc30a
Show file tree
Hide file tree
Showing 6 changed files with 157 additions and 25 deletions.
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ anyhow = "1"
async-recursion = "1.0.5"
axum = { version = "0.6", features = ["ws"] }
axum-server = { version = "0.5.1", features = ["tls-rustls"] }
base64 = "0.21"
bytes = "1"
cargo-lock = "9"
cargo_metadata = "0.15"
Expand All @@ -34,6 +35,7 @@ dunce = "1"
envy = "0.4"
flate2 = "1"
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
hex = "0.4"
htmlescape = "0.3.1"
http-body = "0.4"
humantime = "2"
Expand All @@ -52,6 +54,7 @@ oxipng = "9"
parking_lot = "0.12"
remove_dir_all = "0.8"
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "stream", "trust-dns"] }
sha2 = "0.10"
seahash = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
1 change: 1 addition & 0 deletions site/content/assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ This will typically look like: `<link data-trunk rel="{type}" href="{path}" ..ot
- `data-typescript`: (optional) instruct `wasm-bindgen` to output Typescript bindings. Defaults to false.
- `data-loader-shim`: (optional) instruct `trunk` to create a loader shim for web workers. Defaults to false.
- `data-cross-origin`: (optional) the `crossorigin` setting when loading the code & script resources. Defaults to plain `anonymous`.
- `data-integrity`: (optional) the `integrity` digest type for code & script resources. Defaults to plain `sha384`.

## sass/scss
`rel="sass"` or `rel="scss"`: Trunk uses the official [dart-sass](https://github.com/sass/dart-sass) for compilation. Just link to your sass files from your source HTML, and Trunk will handle the rest. This content is hashed for cache control. The `href` attribute must be included in the link pointing to the sass/scss file to be processed.
Expand Down
3 changes: 2 additions & 1 deletion src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub const STAGE_DIR: &str = ".stage";
pub use manifest::CargoMetadata;
pub use models::{
ConfigOpts, ConfigOptsBuild, ConfigOptsClean, ConfigOptsHook, ConfigOptsProxy, ConfigOptsServe,
ConfigOptsTools, ConfigOptsWatch, CrossOrigin, CrossOriginParseError, WsProtocol,
ConfigOptsTools, ConfigOptsWatch, CrossOrigin, CrossOriginParseError, Integrity,
IntegrityParseError, WsProtocol,
};
pub use rt::{Features, RtcBuild, RtcClean, RtcServe, RtcWatch};
42 changes: 42 additions & 0 deletions src/config/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -712,3 +712,45 @@ pub enum CrossOriginParseError {
#[error("invalid value")]
InvalidValue,
}

/// Integrity type for subresource protection
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
pub enum Integrity {
None,
Sha256,
#[default]
Sha384,
Sha512,
}

impl FromStr for Integrity {
type Err = IntegrityParseError;

fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(match s {
"" => Default::default(),
"none" => Self::None,
"sha256" => Self::Sha256,
"sha384" => Self::Sha384,
"sha512" => Self::Sha512,
_ => return Err(IntegrityParseError::InvalidValue),
})
}
}

impl Display for Integrity {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "none"),
Self::Sha256 => write!(f, "sha256"),
Self::Sha384 => write!(f, "sha384"),
Self::Sha512 => write!(f, "sha512"),
}
}
}

#[derive(Debug, thiserror::Error)]
pub enum IntegrityParseError {
#[error("invalid value")]
InvalidValue,
}
124 changes: 100 additions & 24 deletions src/pipelines/rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ use std::str::FromStr;
use std::sync::Arc;

use anyhow::{anyhow, bail, ensure, Context, Result};
use base64::display::Base64Display;
use base64::engine::general_purpose::URL_SAFE;
use cargo_lock::Lockfile;
use cargo_metadata::camino::Utf8PathBuf;
use minify_js::TopLevelMode;
use nipper::Document;
use sha2::{Digest, Sha256, Sha384, Sha512};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
Expand All @@ -20,7 +23,7 @@ use tokio::task::JoinHandle;

use super::{Attrs, TrunkAssetPipelineOutput, ATTR_HREF, SNIPPETS_DIR};
use crate::common::{self, copy_dir_recursive, path_exists};
use crate::config::{CargoMetadata, ConfigOptsTools, CrossOrigin, Features, RtcBuild};
use crate::config::{CargoMetadata, ConfigOptsTools, CrossOrigin, Features, Integrity, RtcBuild};
use crate::tools::{self, Application};

/// A Rust application pipeline.
Expand Down Expand Up @@ -61,6 +64,8 @@ pub struct RustApp {
loader_shim: bool,
/// Cross origin setting for resources
cross_origin: CrossOrigin,
/// Subresource integrity setting
integrity: Integrity,
}

/// Describes how the rust application is used.
Expand Down Expand Up @@ -139,6 +144,11 @@ impl RustApp {
.map(|val| CrossOrigin::from_str(val))
.transpose()?
.unwrap_or_default();
let integrity = attrs
.get("data-integrity")
.map(|val| Integrity::from_str(val))
.transpose()?
.unwrap_or_default();

let manifest = CargoMetadata::new(&manifest_href).await?;
let id = Some(id);
Expand Down Expand Up @@ -192,6 +202,7 @@ impl RustApp {
name,
loader_shim,
cross_origin,
integrity,
})
}

Expand Down Expand Up @@ -220,7 +231,8 @@ impl RustApp {
app_type: RustAppType::Main,
name,
loader_shim: false,
cross_origin: CrossOrigin::Anonymous,
cross_origin: Default::default(),
integrity: Default::default(),
})
}

Expand All @@ -232,15 +244,17 @@ impl RustApp {

#[tracing::instrument(level = "trace", skip(self))]
async fn build(mut self) -> Result<TrunkAssetPipelineOutput> {
let (wasm, hashed_name) = self.cargo_build().await?;
let output = self.wasm_bindgen_build(wasm.as_ref(), &hashed_name).await?;
let (wasm, hashed_name, integrity) = self.cargo_build().await?;
let output = self
.wasm_bindgen_build(wasm.as_ref(), &hashed_name, integrity)
.await?;
self.wasm_opt_build(&output.wasm_output).await?;
tracing::info!("rust build complete");
Ok(TrunkAssetPipelineOutput::RustApp(output))
}

#[tracing::instrument(level = "trace", skip(self))]
async fn cargo_build(&mut self) -> Result<(PathBuf, String)> {
async fn cargo_build(&mut self) -> Result<(PathBuf, String, IntegrityOutput)> {
tracing::info!("building {}", &self.manifest.package.name);

// Spawn the cargo build process.
Expand Down Expand Up @@ -369,17 +383,29 @@ impl RustApp {
let wasm_bytes = fs::read(&wasm)
.await
.context("error reading wasm file for hash generation")?;
let hashed_name = self
.cfg
.filehash
.then(|| format!("{}-{:x}", self.name, seahash::hash(&wasm_bytes)))
.unwrap_or_else(|| self.name.clone());

Ok((wasm.into_std_path_buf(), hashed_name))
let mut integrity = IntegrityOutput::default();
integrity.wasm = gen_digest(self.integrity, &wasm_bytes);

// generate a hashed name
let hashed_name = match (&self.integrity, self.cfg.filehash) {
(_, false) => self.name.clone(),
(Integrity::None, true) => format!("{}-{:x}", self.name, seahash::hash(&wasm_bytes)),
(_, true) => {
format!("{}-{}", self.name, hex::encode(&integrity.wasm.hash))
}
};

Ok((wasm.into_std_path_buf(), hashed_name, integrity))
}

#[tracing::instrument(level = "trace", skip(self, wasm, hashed_name))]
async fn wasm_bindgen_build(&self, wasm: &Path, hashed_name: &str) -> Result<RustAppOutput> {
async fn wasm_bindgen_build(
&self,
wasm: &Path,
hashed_name: &str,
mut integrity: IntegrityOutput,
) -> Result<RustAppOutput> {
// Skip the hashed file name for workers as their file name must be named at runtime.
// Therefore, workers use the Cargo binary name for file naming.
let hashed_name = match self.app_type {
Expand Down Expand Up @@ -461,7 +487,7 @@ impl RustApp {
"copying {js_loader_path} to {}",
js_loader_path_dist.to_string_lossy()
);
self.copy_or_minify_js(js_loader_path, js_loader_path_dist)
self.copy_or_minify_js(js_loader_path, &js_loader_path_dist)
.await
.context("error minifying or copying JS loader file to stage dir")?;

Expand Down Expand Up @@ -525,6 +551,8 @@ impl RustApp {
.context("error copying snippets dir to stage dir")?;
}

integrity.js = gen_digest(self.integrity, &std::fs::read(js_loader_path_dist)?);

Ok(RustAppOutput {
id: self.id,
cfg: self.cfg.clone(),
Expand All @@ -534,13 +562,14 @@ impl RustApp {
loader_shim_output: hashed_loader_name,
type_: self.app_type,
cross_origin: self.cross_origin,
integrity,
})
}

async fn copy_or_minify_js(
&self,
origin_path: Utf8PathBuf,
destination_path: PathBuf,
destination_path: &Path,
) -> Result<()> {
let bytes = fs::read(origin_path)
.await
Expand Down Expand Up @@ -682,8 +711,10 @@ pub struct RustAppOutput {
pub loader_shim_output: Option<String>,
/// Is this module main or a worker.
pub type_: RustAppType,
/// The crossorigin setting for loading the resources
/// The cross-origin setting for loading the resources
pub cross_origin: CrossOrigin,
/// The integrity and digest of the output, ignored in case of [`Integrity::None`]
pub integrity: IntegrityOutput,
}

pub fn pattern_evaluate(template: &str, params: &HashMap<String, String>) -> String {
Expand Down Expand Up @@ -740,12 +771,11 @@ impl RustAppOutput {
None => {
format!(
r#"
<link rel="preload" href="{base}{wasm}" as="fetch" type="application/wasm" crossorigin={cross_origin}>
<link rel="modulepreload" href="{base}{js}" crossorigin={cross_origin}>"#,
base = base,
js = js,
wasm = wasm,
cross_origin = self.cross_origin
<link rel="preload" href="{base}{wasm}" as="fetch" type="application/wasm" crossorigin={cross_origin}{wasm_integrity}>
<link rel="modulepreload" href="{base}{js}" crossorigin={cross_origin}{js_integrity}>"#,
cross_origin = self.cross_origin,
wasm_integrity = self.integrity.wasm.make_attribute(),
js_integrity = self.integrity.js.make_attribute(),
)
}
};
Expand All @@ -756,9 +786,6 @@ impl RustAppOutput {
None => {
format!(
r#"<script type="module">import init from '{base}{js}';init('{base}{wasm}');</script>"#,
base = base,
js = js,
wasm = wasm,
)
}
};
Expand Down Expand Up @@ -845,3 +872,52 @@ fn check_target_not_found_err(err: anyhow::Error, target: &str) -> anyhow::Error
_ => err,
}
}

/// Integrity of outputs
#[derive(Debug, Default)]
pub struct IntegrityOutput {
pub wasm: OutputDigest,
pub js: OutputDigest,
}

/// The digest of the output
#[derive(Debug)]
pub struct OutputDigest {
pub integrity: Integrity,
pub hash: Vec<u8>,
}

impl Default for OutputDigest {
fn default() -> Self {
Self {
integrity: Integrity::None,
hash: vec![],
}
}
}

impl OutputDigest {
pub fn make_attribute(&self) -> String {
match self.integrity {
Integrity::None => String::default(),
integrity => {
// format of an attribute, including the leading space
format!(
r#" integrity="{integrity}-{hash}""#,
hash = Base64Display::new(&self.hash, &URL_SAFE)
)
}
}
}
}

fn gen_digest(integrity: Integrity, data: &[u8]) -> OutputDigest {
let hash = match integrity {
Integrity::None => vec![],
Integrity::Sha256 => Vec::from_iter(Sha256::digest(data)),
Integrity::Sha384 => Vec::from_iter(Sha384::digest(data)),
Integrity::Sha512 => Vec::from_iter(Sha512::digest(data)),
};

OutputDigest { integrity, hash }
}

0 comments on commit 32fc30a

Please sign in to comment.