use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use primitives_rs::http::csp::csp_sha256_hash_source;
use serde::Deserialize;
use tokio::sync::Mutex;
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, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BuildMetadata {
pub(crate) client_build_id: String,
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,
}
impl TestBuild {
pub(crate) fn into_metadata(self, declarations_hash: String) -> BuildMetadata {
BuildMetadata {
client_build_id: self.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_by_url: HashMap<String, String>,
pub(crate) static_public_assets: Arc<StaticPublicAssets>,
pub(crate) page_csp_sources: Arc<CspSources>,
}
fn to_csp_hash(content: &str) -> String {
format!("'{}'", csp_sha256_hash_source(content.as_bytes()))
}
impl BuildSnapshot {
pub(crate) fn new(metadata: BuildMetadata) -> Self {
Self {
static_public_asset_file_names_by_url: metadata
.static_public_asset_file_names
.iter()
.map(|file_name| {
let url = to_published_file_url(
&metadata.static_public_assets_serve_url_path_prefix,
file_name,
);
(url, file_name.clone())
})
.collect(),
static_public_assets: Arc::new(StaticPublicAssets(
metadata.static_public_asset_urls.clone(),
)),
page_csp_sources: Arc::new(read_csp_sources(&metadata)),
metadata,
}
}
}
fn to_published_file_url(serve_url_path_prefix: &str, file_name: &str) -> String {
let mut url = serve_url_path_prefix.to_owned();
for byte in file_name.bytes() {
if byte == b'/' || byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) {
url.push(char::from(byte));
} else {
url.push_str(&format!("%{byte:02X}"));
}
}
url
}
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 = read_inline_contents(html);
sources.script_src.extend(inline.scripts);
sources.style_src.extend(inline.styles);
}
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);
}
}
sources
}
#[derive(Default)]
struct InlineHashes {
scripts: Vec<String>,
styles: Vec<String>,
}
fn read_inline_contents(html: &str) -> InlineHashes {
let mut hashes = InlineHashes::default();
let lower = LowercaseHtml(html.to_ascii_lowercase());
let mut position = 0;
while let Some(offset) = lower.0[position..].find('<') {
let start = position + offset;
position = start + 1;
let Some(el) = read_inline_el(html, &lower, start) else {
continue;
};
match el.tag {
"script" if !has_src_attr(&lower.0[el.attrs.clone()]) => {
hashes.scripts.push(to_csp_hash(&html[el.content]));
}
"script" => {}
_ => hashes.styles.push(to_csp_hash(&html[el.content])),
}
position = el.end;
}
hashes
}
struct InlineEl {
tag: &'static str,
attrs: std::ops::Range<usize>,
content: std::ops::Range<usize>,
end: usize,
}
struct LowercaseHtml(String);
fn read_inline_el(html: &str, lower: &LowercaseHtml, start: usize) -> Option<InlineEl> {
let after_open = start + 1;
let tag = ["script", "style"]
.into_iter()
.find(|tag| lower.0[after_open..].starts_with(tag))?;
let after_tag = after_open + tag.len();
if lower.0[after_tag..]
.chars()
.next()
.is_some_and(|character| character.is_ascii_alphanumeric() || character == '_')
{
return None;
}
let open_end = after_tag + html[after_tag..].find('>')?;
let closing = format!("</{tag}>");
let content_start = open_end + 1;
let content_end = content_start + lower.0[content_start..].find(&closing)?;
Some(InlineEl {
tag,
attrs: after_tag..open_end,
content: content_start..content_end,
end: content_end + closing.len(),
})
}
fn has_src_attr(attrs: &str) -> bool {
attrs.match_indices("src").any(|(index, _)| {
let starts_word = attrs[..index]
.chars()
.next_back()
.is_none_or(|character| !(character.is_ascii_alphanumeric() || character == '_'));
starts_word && attrs[index + 3..].trim_start().starts_with('=')
})
}
#[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 {
metadata_path: PathBuf,
public_dir: PathBuf,
current: CurrentBuild,
}
enum CurrentBuild {
Prod(Arc<BuildSnapshot>),
Dev(Mutex<DevBuild>),
}
struct DevBuild {
current: (String, Arc<BuildSnapshot>),
failed: Option<(String, Arc<BuildOutputError>)>,
}
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 current = match snapshot.metadata.runtime_mode {
RuntimeMode::Prod => CurrentBuild::Prod(snapshot),
RuntimeMode::Dev => CurrentBuild::Dev(Mutex::new(DevBuild {
current: (text, snapshot),
failed: None,
})),
};
Ok(Self {
metadata_path,
public_dir: build_dir.join("public"),
current,
})
}
pub(crate) fn from_snapshot(snapshot: Arc<BuildSnapshot>) -> Self {
Self {
metadata_path: PathBuf::new(),
public_dir: PathBuf::new(),
current: CurrentBuild::Prod(snapshot),
}
}
pub(crate) fn public_dir(&self) -> &Path {
&self.public_dir
}
pub(crate) async fn read_snapshot(&self) -> Result<Arc<BuildSnapshot>, Arc<BuildOutputError>> {
let state = match &self.current {
CurrentBuild::Prod(snapshot) => return Ok(Arc::clone(snapshot)),
CurrentBuild::Dev(state) => state,
};
let mut state = state.lock().await;
let text = match tokio::fs::read_to_string(&self.metadata_path).await {
Ok(text) => text,
Err(error) => {
return Err(report_build_output_error(BuildOutputError {
message: format!("Vorma could not read {}", self.metadata_path.display()),
source: Some(Box::new(error)),
}));
}
};
if text == state.current.0 {
state.failed = None;
return Ok(Arc::clone(&state.current.1));
}
if let Some((failed_text, error)) = &state.failed
&& *failed_text == text
{
return Err(Arc::clone(error));
}
match parse_metadata(&self.metadata_path, &text) {
Ok(metadata) => {
let snapshot = Arc::new(BuildSnapshot::new(metadata));
state.current = (text, Arc::clone(&snapshot));
state.failed = None;
Ok(snapshot)
}
Err(error) => {
let error = report_build_output_error(error);
state.failed = Some((text, 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>,
}
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(not_metadata)
}