Skip to main content

webui_discovery/
prepared.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4use 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/// A component whose source selection and ownership have already been resolved.
19///
20/// Preparation must validate the owning package boundary and select optional
21/// styles and script ownership. Loading does not repeat those decisions.
22#[derive(Debug)]
23pub struct ComponentFileSource {
24    /// Custom element name used for registration.
25    pub tag_name: String,
26    /// Selected HTML template.
27    pub html: ComponentFile,
28    /// Selected stylesheet, or `None` when no stylesheet was found.
29    pub css: Option<ComponentFile>,
30    /// Whether authored browser code owns the custom element.
31    pub is_client_owned: bool,
32}
33
34/// A selected file and its owning package boundary.
35#[derive(Debug, Hash)]
36pub struct ComponentFile {
37    pub(crate) package_root: Arc<Path>,
38    pub(crate) path: PathBuf,
39}
40
41impl ComponentFile {
42    /// Resolve a selected file within its canonical owning package root.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error when the source cannot be resolved or leaves the package.
47    #[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/// A package's resolved discovery inputs.
67///
68/// File-backed plans can be cached from their decisions and actual source bytes.
69/// Arbitrary computed components are returned without caching because their
70/// inputs cannot be verified by the shared file loader.
71#[derive(Debug)]
72pub enum PreparedPackage {
73    /// A fixed, ordered inventory of component sources.
74    Files(Vec<ComponentFileSource>),
75    /// Already computed components with plugin-owned input semantics.
76    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}