use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use http::HeaderValue;
use primitives_rs::html::escaping::escape_style_text_content;
use primitives_rs::http::csp::{
csp_sha256_inline_content_hash_source, csp_sha256_inline_hash_sources,
};
use serde::{Deserialize, Deserializer};
use tokio::sync::Mutex;
use crate::search::DeploymentQueryKey;
pub const BUILD_METADATA_FORMAT_VERSION: u32 = 1;
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClientModule {
pub(crate) import_url: String,
#[serde(default)]
pub(crate) dep_urls: Vec<String>,
#[serde(default)]
pub(crate) css_bundle_urls: Vec<String>,
#[serde(default)]
pub(crate) referenced_static_public_asset_urls: Vec<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum RuntimeMode {
Prod,
Dev,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ClientBuildId {
text: String,
header_value: HeaderValue,
}
pub(crate) const UNNAMED_BUILD_QUERY_VALUE: &str = "_";
impl ClientBuildId {
pub(crate) fn parse(text: String) -> Result<Self, String> {
if text == UNNAMED_BUILD_QUERY_VALUE {
return Err(format!(
"the client build ID cannot be {text:?}, because a page read with `vorma-json={text}` names no build"
));
}
match HeaderValue::from_str(&text) {
Ok(header_value) if is_visible_ascii(&text) => Ok(Self { text, header_value }),
_ => Err(format!(
"the client build ID {text:?} is not one or more visible ASCII characters"
)),
}
}
pub(crate) fn as_str(&self) -> &str {
&self.text
}
pub(crate) fn header_value(&self) -> &HeaderValue {
&self.header_value
}
}
fn is_visible_ascii(text: &str) -> bool {
!text.is_empty() && text.bytes().all(|byte| byte.is_ascii_graphic())
}
impl<'de> Deserialize<'de> for ClientBuildId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BuildMetadata {
pub(crate) client_build_id: ClientBuildId,
pub(crate) declarations_hash: String,
pub(crate) runtime_mode: RuntimeMode,
pub(crate) static_public_assets_serve_url_path_prefix: String,
pub(crate) static_public_asset_file_names: Vec<String>,
pub(crate) static_public_asset_urls: HashMap<String, String>,
pub(crate) critical_css: String,
pub(crate) dev_head_html: Option<String>,
pub(crate) dev_body_prepend_html: Option<String>,
pub(crate) dev_body_html: Option<String>,
pub(crate) dev_csp: Option<DevCsp>,
pub(crate) client_entry: ClientModule,
pub(crate) client_views: BTreeMap<String, ClientModule>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DevCsp {
pub(crate) module_server_origin: String,
pub(crate) web_socket_origin: String,
pub(crate) nonce: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StaticPublicAssets(HashMap<String, String>);
impl StaticPublicAssets {
pub fn get(&self, src_file_name: &str) -> Option<&str> {
self.0.get(src_file_name).map(String::as_str)
}
}
impl<Name: Into<String>, Url: Into<String>, const COUNT: usize> From<[(Name, Url); COUNT]>
for StaticPublicAssets
{
fn from(pairs: [(Name, Url); COUNT]) -> Self {
pairs.into_iter().collect()
}
}
impl<Name: Into<String>, Url: Into<String>> FromIterator<(Name, Url)> for StaticPublicAssets {
fn from_iter<Pairs: IntoIterator<Item = (Name, Url)>>(pairs: Pairs) -> Self {
Self(
pairs
.into_iter()
.map(|(name, url)| (name.into(), url.into()))
.collect(),
)
}
}
#[derive(Clone, Debug)]
pub struct TestBuild {
pub client_build_id: String,
pub static_public_assets: StaticPublicAssets,
pub skew_protection: SkewProtection,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SkewProtection {
Off,
Vercel {
deployment_id: VercelDeploymentId,
},
}
impl SkewProtection {
pub(crate) fn deployment_id(&self) -> Option<&str> {
match self {
Self::Off => None,
Self::Vercel { deployment_id } => Some(deployment_id.as_str()),
}
}
pub(crate) fn deployment_query_key(&self) -> DeploymentQueryKey {
match self {
Self::Off => DeploymentQueryKey::Ordinary,
Self::Vercel { .. } => DeploymentQueryKey::Reserved,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VercelDeploymentId(String);
impl VercelDeploymentId {
pub fn new(deployment_id: impl Into<String>) -> Option<Self> {
let deployment_id = deployment_id.into();
is_visible_ascii(&deployment_id).then_some(Self(deployment_id))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TestBuild {
pub(crate) fn into_metadata(
self,
client_build_id: ClientBuildId,
declarations_hash: String,
) -> BuildMetadata {
BuildMetadata {
client_build_id,
declarations_hash,
runtime_mode: RuntimeMode::Prod,
static_public_assets_serve_url_path_prefix: "/".to_owned(),
static_public_asset_file_names: Vec::new(),
static_public_asset_urls: self.static_public_assets.0,
critical_css: String::new(),
dev_head_html: None,
dev_body_prepend_html: None,
dev_body_html: None,
dev_csp: None,
client_entry: ClientModule {
import_url: "/vorma-test-build-client-entry.js".to_owned(),
dep_urls: Vec::new(),
css_bundle_urls: Vec::new(),
referenced_static_public_asset_urls: Vec::new(),
},
client_views: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CspSources {
pub script_src: Vec<String>,
pub style_src: Vec<String>,
pub connect_src: Vec<String>,
pub img_src: Vec<String>,
pub font_src: Vec<String>,
pub worker_src: Vec<String>,
}
#[derive(Debug)]
pub(crate) struct BuildSnapshot {
pub(crate) metadata: BuildMetadata,
pub(crate) static_public_asset_file_names: HashSet<String>,
pub(crate) static_public_assets: Arc<StaticPublicAssets>,
pub(crate) page_csp_sources: Arc<CspSources>,
}
fn to_csp_hash(content: &str) -> String {
quote_csp_source(&csp_sha256_inline_content_hash_source(content))
}
fn quote_csp_source(source: &str) -> String {
format!("'{source}'")
}
impl BuildSnapshot {
pub(crate) fn new(mut metadata: BuildMetadata) -> Self {
metadata.critical_css = escape_style_text_content(&metadata.critical_css);
Self {
static_public_asset_file_names: metadata
.static_public_asset_file_names
.iter()
.cloned()
.collect(),
static_public_assets: Arc::new(StaticPublicAssets(
metadata.static_public_asset_urls.clone(),
)),
page_csp_sources: Arc::new(read_csp_sources(&metadata)),
metadata,
}
}
}
fn read_csp_sources(metadata: &BuildMetadata) -> CspSources {
let own_origin = || vec!["'self'".to_owned()];
let mut sources = CspSources {
script_src: own_origin(),
style_src: own_origin(),
connect_src: own_origin(),
img_src: own_origin(),
font_src: own_origin(),
worker_src: Vec::new(),
};
if !metadata.critical_css.is_empty() {
sources.style_src.push(to_csp_hash(&metadata.critical_css));
}
for html in [
&metadata.dev_head_html,
&metadata.dev_body_prepend_html,
&metadata.dev_body_html,
]
.into_iter()
.flatten()
{
let inline = csp_sha256_inline_hash_sources(html);
sources.script_src.extend(
inline
.script_src
.iter()
.map(|source| quote_csp_source(source)),
);
sources.style_src.extend(
inline
.style_src
.iter()
.map(|source| quote_csp_source(source)),
);
}
if let Some(dev_csp) = &metadata.dev_csp {
let origin = &dev_csp.module_server_origin;
for list in [
&mut sources.script_src,
&mut sources.style_src,
&mut sources.img_src,
&mut sources.font_src,
] {
list.push(origin.clone());
}
sources.connect_src.push(dev_csp.web_socket_origin.clone());
sources.worker_src.push("blob:".to_owned());
if let Some(nonce) = &dev_csp.nonce {
let source = format!("'nonce-{nonce}'");
sources.script_src.push(source.clone());
sources.style_src.push(source);
}
}
for module in std::iter::once(&metadata.client_entry).chain(metadata.client_views.values()) {
for script_url in std::iter::once(&module.import_url).chain(module.dep_urls.iter()) {
if let Some(origin) = extract_external_csp_source(script_url) {
push_unique_csp_source(&mut sources.script_src, origin);
}
}
for style_url in &module.css_bundle_urls {
if let Some(origin) = extract_external_csp_source(style_url) {
push_unique_csp_source(&mut sources.style_src, origin);
}
}
for asset_url in &module.referenced_static_public_asset_urls {
record_static_public_asset_csp_source(&mut sources, asset_url);
}
}
let mut static_asset_entries: Vec<(&str, &str)> = metadata
.static_public_asset_urls
.iter()
.map(|(key, url)| (key.as_str(), url.as_str()))
.collect();
static_asset_entries.sort_unstable_by(|left, right| left.0.cmp(right.0));
for (_, asset_url) in static_asset_entries {
record_static_public_asset_csp_source(&mut sources, asset_url);
}
sources
}
fn push_unique_csp_source(sources: &mut Vec<String>, candidate: String) {
if !sources.iter().any(|existing| existing == &candidate) {
sources.push(candidate);
}
}
fn extract_external_csp_source(url: &str) -> Option<String> {
if let Some((scheme, rest)) = url.split_once("://") {
if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
return None;
}
let authority = extract_host_authority(rest)?;
return Some(format!("{}://{authority}", scheme.to_ascii_lowercase()));
}
if let Some(rest) = url.strip_prefix("//") {
let authority = extract_host_authority(rest)?;
return Some(authority.to_owned());
}
None
}
fn extract_host_authority(rest: &str) -> Option<&str> {
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_, host_port)| host_port);
(!host_port.is_empty()).then_some(host_port)
}
fn record_static_public_asset_csp_source(sources: &mut CspSources, url: &str) {
let Some(origin) = extract_external_csp_source(url) else {
return;
};
match crate::payload::static_public_asset_destination(url) {
Some(crate::payload::StaticPublicAssetDestination::Font) => {
push_unique_csp_source(&mut sources.font_src, origin);
}
Some(crate::payload::StaticPublicAssetDestination::Image) => {
push_unique_csp_source(&mut sources.img_src, origin);
}
None => {
let path = url.split(['?', '#']).next().unwrap_or(url);
let extension = path.rsplit_once('.').map_or("", |(_, extension)| extension);
if extension.eq_ignore_ascii_case("css") {
push_unique_csp_source(&mut sources.style_src, origin);
} else if extension.eq_ignore_ascii_case("js") || extension.eq_ignore_ascii_case("mjs")
{
push_unique_csp_source(&mut sources.script_src, origin);
}
}
}
}
#[derive(Debug)]
pub(crate) struct BuildOutputError {
pub(crate) message: String,
pub(crate) source: Option<Box<dyn std::error::Error + Send + Sync>>,
}
impl fmt::Display for BuildOutputError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for BuildOutputError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_deref()
.map(|source| source as &(dyn std::error::Error + 'static))
}
}
pub(crate) struct BuildOutput {
source: BuildSource,
}
enum BuildSource {
Prod {
public_dir: PathBuf,
snapshot: Arc<BuildSnapshot>,
},
Dev {
metadata_path: PathBuf,
public_dir: PathBuf,
current: Mutex<DevBuild>,
},
InMemory {
snapshot: Arc<BuildSnapshot>,
},
}
struct ParsedBuild {
text: String,
snapshot: Arc<BuildSnapshot>,
}
enum DevBuild {
Ready {
current: Arc<ParsedBuild>,
},
Rejected {
current: Arc<ParsedBuild>,
text: String,
error: Arc<BuildOutputError>,
},
}
impl DevBuild {
fn current(&self) -> &Arc<ParsedBuild> {
match self {
Self::Ready { current } | Self::Rejected { current, .. } => current,
}
}
}
impl BuildOutput {
pub(crate) async fn load(build_dir: &Path) -> Result<Self, BuildOutputError> {
let metadata_path = build_dir.join("metadata.json");
let text = tokio::fs::read_to_string(&metadata_path)
.await
.map_err(|error| BuildOutputError {
message: format!(
"Vorma found no metadata.json in the build directory {}. Run vorma build or vorma dev before starting the application.",
build_dir.display()
),
source: Some(Box::new(error)),
})?;
let metadata = parse_metadata(&metadata_path, &text)?;
let snapshot = Arc::new(BuildSnapshot::new(metadata));
let public_dir = build_dir.join("public");
Ok(Self {
source: match snapshot.metadata.runtime_mode {
RuntimeMode::Prod => BuildSource::Prod {
public_dir,
snapshot,
},
RuntimeMode::Dev => BuildSource::Dev {
metadata_path,
public_dir,
current: Mutex::new(DevBuild::Ready {
current: Arc::new(ParsedBuild { text, snapshot }),
}),
},
},
})
}
pub(crate) fn from_snapshot(snapshot: Arc<BuildSnapshot>) -> Self {
Self {
source: BuildSource::InMemory { snapshot },
}
}
pub(crate) fn public_dir(&self) -> Option<&Path> {
match &self.source {
BuildSource::Prod { public_dir, .. } | BuildSource::Dev { public_dir, .. } => {
Some(public_dir)
}
BuildSource::InMemory { .. } => None,
}
}
pub(crate) async fn read_snapshot(&self) -> Result<Arc<BuildSnapshot>, Arc<BuildOutputError>> {
self.read_snapshot_then(|snapshot| snapshot).await
}
pub(crate) async fn read_snapshot_then<Decision>(
&self,
decide: impl FnOnce(Result<Arc<BuildSnapshot>, Arc<BuildOutputError>>) -> Decision,
) -> Decision {
let (metadata_path, state) = match &self.source {
BuildSource::Prod { snapshot, .. } | BuildSource::InMemory { snapshot } => {
return decide(Ok(Arc::clone(snapshot)));
}
BuildSource::Dev {
metadata_path,
current,
..
} => (metadata_path, current),
};
let mut state = state.lock().await;
let snapshot = read_dev_snapshot(metadata_path, &mut state).await;
decide(snapshot)
}
}
async fn read_dev_snapshot(
metadata_path: &Path,
state: &mut DevBuild,
) -> Result<Arc<BuildSnapshot>, Arc<BuildOutputError>> {
let text = match tokio::fs::read_to_string(metadata_path).await {
Ok(text) => text,
Err(error) => {
return Err(report_build_output_error(BuildOutputError {
message: format!("Vorma could not read {}", metadata_path.display()),
source: Some(Box::new(error)),
}));
}
};
if text == state.current().text {
let current = Arc::clone(state.current());
let snapshot = Arc::clone(¤t.snapshot);
*state = DevBuild::Ready { current };
return Ok(snapshot);
}
if let DevBuild::Rejected {
text: failed_text,
error,
..
} = &*state
&& *failed_text == text
{
return Err(Arc::clone(error));
}
match parse_metadata(metadata_path, &text) {
Ok(metadata) => {
let snapshot = Arc::new(BuildSnapshot::new(metadata));
*state = DevBuild::Ready {
current: Arc::new(ParsedBuild {
text,
snapshot: Arc::clone(&snapshot),
}),
};
Ok(snapshot)
}
Err(error) => {
let error = report_build_output_error(error);
*state = DevBuild::Rejected {
current: Arc::clone(state.current()),
text,
error: Arc::clone(&error),
};
Err(error)
}
}
}
fn report_build_output_error(error: BuildOutputError) -> Arc<BuildOutputError> {
tracing::error!(
error = &error as &(dyn std::error::Error + 'static),
"Vorma could not read the build"
);
Arc::new(error)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MetadataFormat {
format_version: Option<u32>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MetadataClientBuildId {
client_build_id: String,
}
fn parse_metadata(path: &Path, text: &str) -> Result<BuildMetadata, BuildOutputError> {
let not_metadata = |error: serde_json::Error| BuildOutputError {
message: format!("{} is not Vorma build metadata", path.display()),
source: Some(Box::new(error)),
};
let format: MetadataFormat = serde_json::from_str(text).map_err(not_metadata)?;
if format.format_version != Some(BUILD_METADATA_FORMAT_VERSION) {
let found = format
.format_version
.map_or_else(|| "none".to_owned(), |version| version.to_string());
return Err(BuildOutputError {
message: format!(
"{} has build metadata format version {found}, but this version of the vorma crate reads format version {BUILD_METADATA_FORMAT_VERSION}. Rebuild with matching versions of the vorma package and the vorma crate.",
path.display()
),
source: None,
});
}
serde_json::from_str(text).map_err(|error| {
match serde_json::from_str::<MetadataClientBuildId>(text)
.map(|metadata| ClientBuildId::parse(metadata.client_build_id))
{
Ok(Err(problem)) => BuildOutputError {
message: format!("{} is invalid: {problem}", path.display()),
source: None,
},
Ok(Ok(_)) | Err(_) => not_metadata(error),
}
})
}