1use std::collections::hash_map::DefaultHasher;
5use std::fs::File;
6use std::hash::{Hash, Hasher};
7use std::io::{self, Read};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use anyhow::{bail, Context, Result};
12
13use crate::npm::{package_asset_metadata, MAX_MANIFEST_SIZE};
14use crate::DiscoveredComponent;
15
16const HASH_BUFFER_SIZE: usize = 8 * 1024;
17
18#[derive(Debug)]
23pub struct ComponentFileSource {
24 pub tag_name: String,
26 pub html: ComponentFile,
28 pub css: Option<ComponentFile>,
30 pub is_client_owned: bool,
32}
33
34#[derive(Debug, Hash)]
36pub struct ComponentFile {
37 pub(crate) package_root: Arc<Path>,
38 pub(crate) path: PathBuf,
39}
40
41impl ComponentFile {
42 #[must_use = "the resolved file belongs in a prepared component source"]
48 pub fn new(package_root: Arc<Path>, path: &Path) -> Result<Self> {
49 let resolved = path
50 .canonicalize()
51 .with_context(|| format!("Cannot resolve component source {}", path.display()))?;
52 if !resolved.starts_with(package_root.as_ref()) {
53 bail!(
54 "Component source resolves outside package {}: {}",
55 package_root.display(),
56 path.display()
57 );
58 }
59 Ok(Self {
60 package_root,
61 path: resolved,
62 })
63 }
64}
65
66#[derive(Debug)]
72pub enum PreparedPackage {
73 Files(Vec<ComponentFileSource>),
75 Uncached(Vec<DiscoveredComponent>),
77}
78
79pub(crate) struct LoadedPackage {
80 components: Vec<DiscoveredComponent>,
81 fingerprint: u64,
82}
83
84impl LoadedPackage {
85 pub(crate) fn components(&self) -> &[DiscoveredComponent] {
86 &self.components
87 }
88
89 pub(crate) fn fingerprint(&self) -> u64 {
90 self.fingerprint
91 }
92
93 pub(crate) fn into_components(self) -> Vec<DiscoveredComponent> {
94 self.components
95 }
96}
97
98fn start_fingerprint(package_json: &str, count: usize) -> DefaultHasher {
99 let mut hasher = DefaultHasher::new();
100 package_json.hash(&mut hasher);
101 count.hash(&mut hasher);
102 hasher
103}
104
105fn hash_source(input: &ComponentFileSource, hasher: &mut DefaultHasher) {
106 input.tag_name.hash(hasher);
107 input.html.hash(hasher);
108 input.css.hash(hasher);
109 input.is_client_owned.hash(hasher);
110}
111
112pub(crate) fn fingerprint(package_json: &str, inputs: &[ComponentFileSource]) -> Result<u64> {
113 let mut hasher = start_fingerprint(package_json, inputs.len());
114 let mut buffer = [0; HASH_BUFFER_SIZE];
115 for input in inputs {
116 hash_source(input, &mut hasher);
117 hash_file(&input.html.package_root, &input.html.path, &mut buffer)?.hash(&mut hasher);
118 if let Some(file) = &input.css {
119 hash_file(&file.package_root, &file.path, &mut buffer)?.hash(&mut hasher);
120 }
121 }
122 Ok(hasher.finish())
123}
124
125pub(crate) fn load(
126 source: &str,
127 package_json: &str,
128 inputs: Vec<ComponentFileSource>,
129) -> Result<LoadedPackage> {
130 let mut hasher = start_fingerprint(package_json, inputs.len());
131 let mut components = Vec::with_capacity(inputs.len());
132 for input in inputs {
133 hash_source(&input, &mut hasher);
134 let html_content = read_source(&input.html.package_root, &input.html.path)?;
135 hash_bytes(html_content.as_bytes()).hash(&mut hasher);
136 let css_content = if let Some(file) = &input.css {
137 let content = read_source(&file.package_root, &file.path)?;
138 hash_bytes(content.as_bytes()).hash(&mut hasher);
139 Some(content)
140 } else {
141 None
142 };
143 components.push(DiscoveredComponent {
144 tag_name: input.tag_name,
145 html_content,
146 css_content,
147 is_client_owned: input.is_client_owned,
148 source: source.to_string(),
149 });
150 }
151 Ok(LoadedPackage {
152 components,
153 fingerprint: hasher.finish(),
154 })
155}
156
157fn open_source(root: &Path, path: &Path) -> Result<(File, u64)> {
158 let metadata = package_asset_metadata(root, path)?.with_context(|| {
159 format!(
160 "Component source disappeared: {}. Retry after source generation finishes.",
161 path.display()
162 )
163 })?;
164 if !metadata.is_file() {
165 bail!("Component source must be a file: {}", path.display());
166 }
167 if metadata.len() > MAX_MANIFEST_SIZE {
168 return Err(oversized_source(path));
169 }
170 let file = File::open(path)
171 .with_context(|| format!("Failed to open component source {}", path.display()))?;
172 Ok((file, metadata.len()))
173}
174
175fn read_source(root: &Path, path: &Path) -> Result<String> {
176 let (file, size) = open_source(root, path)?;
177 let capacity =
178 usize::try_from(size).context("Component source is too large for this platform")?;
179 let mut content = String::with_capacity(capacity);
180 let mut reader = file.take(MAX_MANIFEST_SIZE + 1);
181 reader
182 .read_to_string(&mut content)
183 .with_context(|| format!("Failed to read component source {}", path.display()))?;
184 if reader.limit() == 0 {
185 return Err(oversized_source(path));
186 }
187 Ok(content)
188}
189
190fn hash_file(root: &Path, path: &Path, buffer: &mut [u8; HASH_BUFFER_SIZE]) -> Result<u64> {
191 let (file, _) = open_source(root, path)?;
192 let mut reader = file.take(MAX_MANIFEST_SIZE + 1);
193 let hash = hash_contents(&mut reader, buffer)
194 .with_context(|| format!("Failed to hash component source {}", path.display()))?;
195 if reader.limit() == 0 {
196 return Err(oversized_source(path));
197 }
198 Ok(hash)
199}
200
201fn hash_bytes(bytes: &[u8]) -> u64 {
202 let mut hasher = DefaultHasher::new();
203 hasher.write(bytes);
204 hasher.finish()
205}
206
207fn hash_contents(reader: &mut impl Read, buffer: &mut [u8; HASH_BUFFER_SIZE]) -> io::Result<u64> {
208 let mut hasher = DefaultHasher::new();
209 loop {
210 match reader.read(buffer) {
211 Ok(0) => return Ok(hasher.finish()),
212 Ok(count) => hasher.write(&buffer[..count]),
213 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
214 Err(error) => return Err(error),
215 }
216 }
217}
218
219#[cold]
220#[inline(never)]
221fn oversized_source(path: &Path) -> anyhow::Error {
222 anyhow::anyhow!(
223 "Component source exceeds {MAX_MANIFEST_SIZE} bytes: {}. \
224 Reduce the source size before building.",
225 path.display()
226 )
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::fs;
233 use std::io::{Cursor, Seek, SeekFrom, Write};
234
235 #[test]
236 fn selected_file_must_resolve_inside_its_package() -> Result<()> {
237 let root = tempfile::tempdir()?;
238 fs::create_dir(root.path().join("package"))?;
239 fs::write(root.path().join("package/template.html"), "<p>Inside</p>")?;
240 fs::write(root.path().join("outside.html"), "<p>Outside</p>")?;
241 let boundary: Arc<Path> = root.path().join("package").canonicalize()?.into();
242 let input = ComponentFile::new(Arc::clone(&boundary), &boundary.join("template.html"))?;
243 assert_eq!(input.path, boundary.join("template.html"));
244 assert!(ComponentFile::new(Arc::clone(&boundary), &boundary.join("missing.html")).is_err());
245 assert!(ComponentFile::new(boundary, &root.path().join("outside.html")).is_err());
246 Ok(())
247 }
248
249 struct ShortReads<'a> {
250 content: Cursor<&'a [u8]>,
251 interrupted: bool,
252 }
253
254 impl Read for ShortReads<'_> {
255 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
256 assert!(buffer.len() <= HASH_BUFFER_SIZE);
257 if !self.interrupted {
258 self.interrupted = true;
259 return Err(io::ErrorKind::Interrupted.into());
260 }
261 let count = buffer.len().min(37);
262 self.content.read(&mut buffer[..count])
263 }
264 }
265
266 #[test]
267 fn streaming_hash_is_bounded_and_independent_of_read_boundaries() -> Result<()> {
268 let mut buffer = [0; HASH_BUFFER_SIZE];
269 for size in [0, 1, 8191, 8192, 8193, 24595] {
270 let mut content = vec![b'x'; size];
271 if let Some(last) = content.last_mut() {
272 *last = b'y';
273 }
274 let mut reader = ShortReads {
275 content: Cursor::new(&content),
276 interrupted: false,
277 };
278 assert_eq!(
279 hash_contents(&mut reader, &mut buffer)?,
280 hash_bytes(&content)
281 );
282 }
283 Ok(())
284 }
285
286 struct FailingRead(io::ErrorKind);
287
288 impl Read for FailingRead {
289 fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
290 Err(self.0.into())
291 }
292 }
293
294 #[test]
295 fn streaming_hash_propagates_read_failures() {
296 let mut buffer = [0; HASH_BUFFER_SIZE];
297 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::NotFound] {
298 let result = hash_contents(&mut FailingRead(kind), &mut buffer);
299 assert!(matches!(result, Err(error) if error.kind() == kind));
300 }
301 }
302
303 #[test]
304 fn fingerprint_tracks_all_selected_bytes_and_rejects_missing_sources() -> Result<()> {
305 let root = tempfile::tempdir()?;
306 let boundary = root.path().canonicalize()?;
307 for extension in ["json", "html", "css", "ts", "js"] {
308 let path = boundary.join(format!("input.{extension}"));
309 let inputs = vec![ComponentFileSource {
310 tag_name: "test-input".to_string(),
311 html: ComponentFile {
312 package_root: boundary.as_path().into(),
313 path: path.clone(),
314 },
315 css: None,
316 is_client_owned: false,
317 }];
318 assert!(fingerprint("{}", &inputs).is_err());
319 let mut file = fs::File::create(&path)?;
320 let empty = fingerprint("{}", &inputs)?;
321 file.write_all(&[b'x'; HASH_BUFFER_SIZE])?;
322 file.write_all(b"tail")?;
323 let original = fingerprint("{}", &inputs)?;
324 assert_ne!(empty, original);
325 file.seek(SeekFrom::End(-1))?;
326 file.write_all(b"!")?;
327 let changed = fingerprint("{}", &inputs)?;
328 assert_ne!(original, changed, "{extension} tail was not hashed");
329 drop(file);
330 assert_eq!(load("test", "{}", inputs)?.fingerprint(), changed);
331 fs::remove_file(path)?;
332 }
333 Ok(())
334 }
335}