use std::collections::hash_map::DefaultHasher;
use std::fs::File;
use std::hash::{Hash, Hasher};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{bail, Context, Result};
use crate::npm::{package_asset_metadata, MAX_MANIFEST_SIZE};
use crate::DiscoveredComponent;
const HASH_BUFFER_SIZE: usize = 8 * 1024;
#[derive(Debug)]
pub struct ComponentFileSource {
pub tag_name: String,
pub html: ComponentFile,
pub css: Option<ComponentFile>,
pub is_client_owned: bool,
}
#[derive(Debug, Hash)]
pub struct ComponentFile {
pub(crate) package_root: Arc<Path>,
pub(crate) path: PathBuf,
}
impl ComponentFile {
#[must_use = "the resolved file belongs in a prepared component source"]
pub fn new(package_root: Arc<Path>, path: &Path) -> Result<Self> {
let resolved = path
.canonicalize()
.with_context(|| format!("Cannot resolve component source {}", path.display()))?;
if !resolved.starts_with(package_root.as_ref()) {
bail!(
"Component source resolves outside package {}: {}",
package_root.display(),
path.display()
);
}
Ok(Self {
package_root,
path: resolved,
})
}
}
#[derive(Debug)]
pub enum PreparedPackage {
Files(Vec<ComponentFileSource>),
Uncached(Vec<DiscoveredComponent>),
}
pub(crate) struct LoadedPackage {
components: Vec<DiscoveredComponent>,
fingerprint: u64,
}
impl LoadedPackage {
pub(crate) fn components(&self) -> &[DiscoveredComponent] {
&self.components
}
pub(crate) fn fingerprint(&self) -> u64 {
self.fingerprint
}
pub(crate) fn into_components(self) -> Vec<DiscoveredComponent> {
self.components
}
}
fn start_fingerprint(package_json: &str, count: usize) -> DefaultHasher {
let mut hasher = DefaultHasher::new();
package_json.hash(&mut hasher);
count.hash(&mut hasher);
hasher
}
fn hash_source(input: &ComponentFileSource, hasher: &mut DefaultHasher) {
input.tag_name.hash(hasher);
input.html.hash(hasher);
input.css.hash(hasher);
input.is_client_owned.hash(hasher);
}
pub(crate) fn fingerprint(package_json: &str, inputs: &[ComponentFileSource]) -> Result<u64> {
let mut hasher = start_fingerprint(package_json, inputs.len());
let mut buffer = [0; HASH_BUFFER_SIZE];
for input in inputs {
hash_source(input, &mut hasher);
hash_file(&input.html.package_root, &input.html.path, &mut buffer)?.hash(&mut hasher);
if let Some(file) = &input.css {
hash_file(&file.package_root, &file.path, &mut buffer)?.hash(&mut hasher);
}
}
Ok(hasher.finish())
}
pub(crate) fn load(
source: &str,
package_json: &str,
inputs: Vec<ComponentFileSource>,
) -> Result<LoadedPackage> {
let mut hasher = start_fingerprint(package_json, inputs.len());
let mut components = Vec::with_capacity(inputs.len());
for input in inputs {
hash_source(&input, &mut hasher);
let html_content = read_source(&input.html.package_root, &input.html.path)?;
hash_bytes(html_content.as_bytes()).hash(&mut hasher);
let css_content = if let Some(file) = &input.css {
let content = read_source(&file.package_root, &file.path)?;
hash_bytes(content.as_bytes()).hash(&mut hasher);
Some(content)
} else {
None
};
components.push(DiscoveredComponent {
tag_name: input.tag_name,
html_content,
css_content,
is_client_owned: input.is_client_owned,
source: source.to_string(),
});
}
Ok(LoadedPackage {
components,
fingerprint: hasher.finish(),
})
}
fn open_source(root: &Path, path: &Path) -> Result<(File, u64)> {
let metadata = package_asset_metadata(root, path)?.with_context(|| {
format!(
"Component source disappeared: {}. Retry after source generation finishes.",
path.display()
)
})?;
if !metadata.is_file() {
bail!("Component source must be a file: {}", path.display());
}
if metadata.len() > MAX_MANIFEST_SIZE {
return Err(oversized_source(path));
}
let file = File::open(path)
.with_context(|| format!("Failed to open component source {}", path.display()))?;
Ok((file, metadata.len()))
}
fn read_source(root: &Path, path: &Path) -> Result<String> {
let (file, size) = open_source(root, path)?;
let capacity =
usize::try_from(size).context("Component source is too large for this platform")?;
let mut content = String::with_capacity(capacity);
let mut reader = file.take(MAX_MANIFEST_SIZE + 1);
reader
.read_to_string(&mut content)
.with_context(|| format!("Failed to read component source {}", path.display()))?;
if reader.limit() == 0 {
return Err(oversized_source(path));
}
Ok(content)
}
fn hash_file(root: &Path, path: &Path, buffer: &mut [u8; HASH_BUFFER_SIZE]) -> Result<u64> {
let (file, _) = open_source(root, path)?;
let mut reader = file.take(MAX_MANIFEST_SIZE + 1);
let hash = hash_contents(&mut reader, buffer)
.with_context(|| format!("Failed to hash component source {}", path.display()))?;
if reader.limit() == 0 {
return Err(oversized_source(path));
}
Ok(hash)
}
fn hash_bytes(bytes: &[u8]) -> u64 {
let mut hasher = DefaultHasher::new();
hasher.write(bytes);
hasher.finish()
}
fn hash_contents(reader: &mut impl Read, buffer: &mut [u8; HASH_BUFFER_SIZE]) -> io::Result<u64> {
let mut hasher = DefaultHasher::new();
loop {
match reader.read(buffer) {
Ok(0) => return Ok(hasher.finish()),
Ok(count) => hasher.write(&buffer[..count]),
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
#[cold]
#[inline(never)]
fn oversized_source(path: &Path) -> anyhow::Error {
anyhow::anyhow!(
"Component source exceeds {MAX_MANIFEST_SIZE} bytes: {}. \
Reduce the source size before building.",
path.display()
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::{Cursor, Seek, SeekFrom, Write};
#[test]
fn selected_file_must_resolve_inside_its_package() -> Result<()> {
let root = tempfile::tempdir()?;
fs::create_dir(root.path().join("package"))?;
fs::write(root.path().join("package/template.html"), "<p>Inside</p>")?;
fs::write(root.path().join("outside.html"), "<p>Outside</p>")?;
let boundary: Arc<Path> = root.path().join("package").canonicalize()?.into();
let input = ComponentFile::new(Arc::clone(&boundary), &boundary.join("template.html"))?;
assert_eq!(input.path, boundary.join("template.html"));
assert!(ComponentFile::new(Arc::clone(&boundary), &boundary.join("missing.html")).is_err());
assert!(ComponentFile::new(boundary, &root.path().join("outside.html")).is_err());
Ok(())
}
struct ShortReads<'a> {
content: Cursor<&'a [u8]>,
interrupted: bool,
}
impl Read for ShortReads<'_> {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
assert!(buffer.len() <= HASH_BUFFER_SIZE);
if !self.interrupted {
self.interrupted = true;
return Err(io::ErrorKind::Interrupted.into());
}
let count = buffer.len().min(37);
self.content.read(&mut buffer[..count])
}
}
#[test]
fn streaming_hash_is_bounded_and_independent_of_read_boundaries() -> Result<()> {
let mut buffer = [0; HASH_BUFFER_SIZE];
for size in [0, 1, 8191, 8192, 8193, 24595] {
let mut content = vec![b'x'; size];
if let Some(last) = content.last_mut() {
*last = b'y';
}
let mut reader = ShortReads {
content: Cursor::new(&content),
interrupted: false,
};
assert_eq!(
hash_contents(&mut reader, &mut buffer)?,
hash_bytes(&content)
);
}
Ok(())
}
struct FailingRead(io::ErrorKind);
impl Read for FailingRead {
fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
Err(self.0.into())
}
}
#[test]
fn streaming_hash_propagates_read_failures() {
let mut buffer = [0; HASH_BUFFER_SIZE];
for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::NotFound] {
let result = hash_contents(&mut FailingRead(kind), &mut buffer);
assert!(matches!(result, Err(error) if error.kind() == kind));
}
}
#[test]
fn fingerprint_tracks_all_selected_bytes_and_rejects_missing_sources() -> Result<()> {
let root = tempfile::tempdir()?;
let boundary = root.path().canonicalize()?;
for extension in ["json", "html", "css", "ts", "js"] {
let path = boundary.join(format!("input.{extension}"));
let inputs = vec![ComponentFileSource {
tag_name: "test-input".to_string(),
html: ComponentFile {
package_root: boundary.as_path().into(),
path: path.clone(),
},
css: None,
is_client_owned: false,
}];
assert!(fingerprint("{}", &inputs).is_err());
let mut file = fs::File::create(&path)?;
let empty = fingerprint("{}", &inputs)?;
file.write_all(&[b'x'; HASH_BUFFER_SIZE])?;
file.write_all(b"tail")?;
let original = fingerprint("{}", &inputs)?;
assert_ne!(empty, original);
file.seek(SeekFrom::End(-1))?;
file.write_all(b"!")?;
let changed = fingerprint("{}", &inputs)?;
assert_ne!(original, changed, "{extension} tail was not hashed");
drop(file);
assert_eq!(load("test", "{}", inputs)?.fingerprint(), changed);
fs::remove_file(path)?;
}
Ok(())
}
}