Skip to main content

mesh_llm_plugin/manifest/
web_ui.rs

1use crate::proto;
2use anyhow::{Result, bail};
3use serde::{Deserialize, Serialize};
4use std::path::{Component, Path};
5
6const INTEGRATIONS_PARENT_TAB: &str = "integrations";
7
8#[derive(Clone, Debug)]
9pub struct PluginWebUiBuilder {
10    inner: proto::PluginWebUiManifest,
11}
12
13#[derive(Clone, Debug)]
14pub struct PluginWebUiPageBuilder {
15    inner: proto::PluginWebUiPageManifest,
16}
17
18#[derive(Clone, Debug)]
19pub struct PluginWebUiConfigSectionBuilder {
20    inner: proto::PluginWebUiConfigSectionManifest,
21}
22
23#[derive(Clone, Debug)]
24pub struct PluginWebUiBundleBuilder {
25    inner: proto::PluginWebUiBundleManifest,
26}
27
28pub fn web_ui() -> PluginWebUiBuilder {
29    PluginWebUiBuilder {
30        inner: proto::PluginWebUiManifest::default(),
31    }
32}
33
34pub fn web_ui_page(
35    id: impl Into<String>,
36    label: impl Into<String>,
37    route: impl Into<String>,
38    entry_script: impl Into<String>,
39) -> PluginWebUiPageBuilder {
40    PluginWebUiPageBuilder {
41        inner: proto::PluginWebUiPageManifest {
42            id: id.into(),
43            label: label.into(),
44            icon: None,
45            route: route.into(),
46            bundle_id: String::new(),
47            entry_script: entry_script.into(),
48        },
49    }
50}
51
52pub fn web_ui_config_section(
53    id: impl Into<String>,
54    title: impl Into<String>,
55    entry_script: impl Into<String>,
56) -> PluginWebUiConfigSectionBuilder {
57    PluginWebUiConfigSectionBuilder {
58        inner: proto::PluginWebUiConfigSectionManifest {
59            id: id.into(),
60            title: title.into(),
61            entry_script: entry_script.into(),
62            parent_tab: None,
63            bundle_id: String::new(),
64        },
65    }
66}
67
68pub fn web_ui_bundle(
69    id: impl Into<String>,
70    root_path: impl Into<String>,
71) -> PluginWebUiBundleBuilder {
72    PluginWebUiBundleBuilder {
73        inner: proto::PluginWebUiBundleManifest {
74            id: id.into(),
75            root_path: root_path.into(),
76        },
77    }
78}
79
80impl PluginWebUiBuilder {
81    pub fn page<T: Into<proto::PluginWebUiPageManifest>>(mut self, page: T) -> Self {
82        self.inner.pages.push(page.into());
83        self
84    }
85
86    pub fn config_section<T: Into<proto::PluginWebUiConfigSectionManifest>>(
87        mut self,
88        section: T,
89    ) -> Self {
90        self.inner.config_sections.push(section.into());
91        self
92    }
93
94    pub fn bundle<T: Into<proto::PluginWebUiBundleManifest>>(mut self, bundle: T) -> Self {
95        self.inner.bundles.push(bundle.into());
96        self
97    }
98}
99
100impl PluginWebUiPageBuilder {
101    pub fn icon(mut self, icon: impl Into<String>) -> Self {
102        self.inner.icon = Some(icon.into());
103        self
104    }
105
106    pub fn bundle_id(mut self, bundle_id: impl Into<String>) -> Self {
107        self.inner.bundle_id = bundle_id.into();
108        self
109    }
110}
111
112impl PluginWebUiConfigSectionBuilder {
113    pub fn parent_tab(mut self, parent_tab: impl Into<String>) -> Self {
114        self.inner.parent_tab = Some(parent_tab.into());
115        self
116    }
117
118    pub fn bundle_id(mut self, bundle_id: impl Into<String>) -> Self {
119        self.inner.bundle_id = bundle_id.into();
120        self
121    }
122}
123
124impl From<PluginWebUiBuilder> for proto::PluginWebUiManifest {
125    fn from(value: PluginWebUiBuilder) -> Self {
126        value.inner
127    }
128}
129
130impl From<PluginWebUiPageBuilder> for proto::PluginWebUiPageManifest {
131    fn from(value: PluginWebUiPageBuilder) -> Self {
132        value.inner
133    }
134}
135
136impl From<PluginWebUiConfigSectionBuilder> for proto::PluginWebUiConfigSectionManifest {
137    fn from(value: PluginWebUiConfigSectionBuilder) -> Self {
138        value.inner
139    }
140}
141
142impl From<PluginWebUiBundleBuilder> for proto::PluginWebUiBundleManifest {
143    fn from(value: PluginWebUiBundleBuilder) -> Self {
144        value.inner
145    }
146}
147
148#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
149pub(super) struct PackagedPluginWebUi {
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub pages: Vec<PackagedPluginWebUiPage>,
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub config_sections: Vec<PackagedPluginWebUiConfigSection>,
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub bundles: Vec<PackagedPluginWebUiBundle>,
156}
157
158#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
159pub(super) struct PackagedPluginWebUiPage {
160    pub id: String,
161    pub label: String,
162    #[serde(skip_serializing_if = "Option::is_none", default)]
163    pub icon: Option<String>,
164    pub route: String,
165    pub bundle_id: String,
166    pub entry_script: String,
167}
168
169#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
170pub(super) struct PackagedPluginWebUiConfigSection {
171    pub id: String,
172    pub title: String,
173    pub entry_script: String,
174    #[serde(skip_serializing_if = "Option::is_none", default)]
175    pub parent_tab: Option<String>,
176    pub bundle_id: String,
177}
178
179#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
180pub(super) struct PackagedPluginWebUiBundle {
181    pub id: String,
182    pub root_path: String,
183}
184
185impl TryFrom<&proto::PluginWebUiManifest> for PackagedPluginWebUi {
186    type Error = anyhow::Error;
187
188    fn try_from(value: &proto::PluginWebUiManifest) -> Result<Self> {
189        let bundle_id = validate_v1_bundle_contract(value)?;
190        let pages = value
191            .pages
192            .iter()
193            .map(|page| {
194                PackagedPluginWebUiPage::try_from_with_bundle_id(page, bundle_id.as_deref())
195            })
196            .collect::<Result<Vec<_>>>()?;
197        let config_sections = value
198            .config_sections
199            .iter()
200            .map(|section| {
201                PackagedPluginWebUiConfigSection::try_from_with_bundle_id(
202                    section,
203                    bundle_id.as_deref(),
204                )
205            })
206            .collect::<Result<Vec<_>>>()?;
207        let bundles = value
208            .bundles
209            .iter()
210            .map(PackagedPluginWebUiBundle::try_from)
211            .collect::<Result<Vec<_>>>()?;
212
213        Ok(Self {
214            pages,
215            config_sections,
216            bundles,
217        })
218    }
219}
220
221impl TryFrom<&proto::PluginWebUiPageManifest> for PackagedPluginWebUiPage {
222    type Error = anyhow::Error;
223
224    fn try_from(value: &proto::PluginWebUiPageManifest) -> Result<Self> {
225        Self::try_from_with_bundle_id(value, None)
226    }
227}
228
229impl PackagedPluginWebUiPage {
230    fn try_from_with_bundle_id(
231        value: &proto::PluginWebUiPageManifest,
232        expected_bundle_id: Option<&str>,
233    ) -> Result<Self> {
234        validate_non_empty("web UI page id", &value.id)?;
235        validate_non_empty("web UI page label", &value.label)?;
236        validate_route_slug("web UI page route", &value.route)?;
237        validate_bundle_reference(
238            "web UI page bundle_id",
239            &value.bundle_id,
240            expected_bundle_id,
241        )?;
242        validate_relative_path("web UI page entry_script", &value.entry_script)?;
243        if let Some(icon) = &value.icon {
244            validate_relative_path("web UI page icon", icon)?;
245        }
246        Ok(Self {
247            id: value.id.clone(),
248            label: value.label.clone(),
249            icon: value.icon.clone(),
250            route: value.route.clone(),
251            bundle_id: value.bundle_id.clone(),
252            entry_script: value.entry_script.clone(),
253        })
254    }
255}
256
257impl TryFrom<&proto::PluginWebUiConfigSectionManifest> for PackagedPluginWebUiConfigSection {
258    type Error = anyhow::Error;
259
260    fn try_from(value: &proto::PluginWebUiConfigSectionManifest) -> Result<Self> {
261        Self::try_from_with_bundle_id(value, None)
262    }
263}
264
265impl PackagedPluginWebUiConfigSection {
266    fn try_from_with_bundle_id(
267        value: &proto::PluginWebUiConfigSectionManifest,
268        expected_bundle_id: Option<&str>,
269    ) -> Result<Self> {
270        validate_non_empty("web UI config section id", &value.id)?;
271        validate_non_empty("web UI config section title", &value.title)?;
272        validate_bundle_reference(
273            "web UI config section bundle_id",
274            &value.bundle_id,
275            expected_bundle_id,
276        )?;
277        validate_relative_path("web UI config section entry_script", &value.entry_script)?;
278        if let Some(parent_tab) = &value.parent_tab {
279            validate_config_parent_tab(parent_tab)?;
280        }
281        Ok(Self {
282            id: value.id.clone(),
283            title: value.title.clone(),
284            entry_script: value.entry_script.clone(),
285            parent_tab: value.parent_tab.clone(),
286            bundle_id: value.bundle_id.clone(),
287        })
288    }
289}
290
291impl TryFrom<&proto::PluginWebUiBundleManifest> for PackagedPluginWebUiBundle {
292    type Error = anyhow::Error;
293
294    fn try_from(value: &proto::PluginWebUiBundleManifest) -> Result<Self> {
295        validate_non_empty("web UI bundle id", &value.id)?;
296        validate_relative_path("web UI bundle root_path", &value.root_path)?;
297        Ok(Self {
298            id: value.id.clone(),
299            root_path: value.root_path.clone(),
300        })
301    }
302}
303
304fn validate_v1_bundle_contract(value: &proto::PluginWebUiManifest) -> Result<Option<String>> {
305    if value.pages.is_empty() && value.config_sections.is_empty() && value.bundles.is_empty() {
306        return Ok(None);
307    }
308    let [bundle] = value.bundles.as_slice() else {
309        bail!(
310            "web UI v1 declarations with pages or config sections must declare exactly one bundle root"
311        );
312    };
313    validate_non_empty("web UI bundle id", &bundle.id)?;
314    Ok(Some(bundle.id.clone()))
315}
316
317fn validate_bundle_reference(
318    field_name: &str,
319    value: &str,
320    expected_bundle_id: Option<&str>,
321) -> Result<()> {
322    validate_non_empty(field_name, value)?;
323    if let Some(expected) = expected_bundle_id
324        && value != expected
325    {
326        bail!("{field_name} must reference declared web UI bundle `{expected}`, got `{value}`");
327    }
328    Ok(())
329}
330
331fn validate_non_empty(field_name: &str, value: &str) -> Result<()> {
332    if value.trim().is_empty() {
333        bail!("{field_name} must be non-empty");
334    }
335    Ok(())
336}
337
338fn validate_config_parent_tab(parent_tab: &str) -> Result<()> {
339    if parent_tab != INTEGRATIONS_PARENT_TAB {
340        bail!("web UI config section parent_tab must be `integrations`");
341    }
342    Ok(())
343}
344
345fn validate_route_slug(field_name: &str, value: &str) -> Result<()> {
346    validate_non_empty(field_name, value)?;
347    if has_remote_url_scheme(value) || value.contains("://") {
348        bail!("{field_name} must be a slug, got URL-like value `{value}`");
349    }
350    if value.contains('/') || value.contains('\\') {
351        bail!("{field_name} must be a slug without path separators `{value}`");
352    }
353    if value == "." || value == ".." || value.starts_with('.') {
354        bail!("{field_name} must be a slug without traversal or hidden path syntax `{value}`");
355    }
356    Ok(())
357}
358
359fn validate_relative_path(field_name: &str, value: &str) -> Result<()> {
360    validate_non_empty(field_name, value)?;
361    if has_remote_url_scheme(value) {
362        bail!("{field_name} must be a relative path, got remote URL `{value}`");
363    }
364    let path = Path::new(value);
365    if path.is_absolute() {
366        bail!("{field_name} must be a relative path, got absolute path `{value}`");
367    }
368    if path
369        .components()
370        .all(|component| matches!(component, Component::CurDir))
371    {
372        bail!("{field_name} must name a file or directory below the package root");
373    }
374    if path
375        .components()
376        .any(|component| matches!(component, Component::ParentDir))
377    {
378        bail!("{field_name} must not contain traversal segments `{value}`");
379    }
380    if path.components().any(|component| match component {
381        Component::Normal(name) => name.to_string_lossy().starts_with('.'),
382        _ => false,
383    }) {
384        bail!("{field_name} must not contain hidden path segments `{value}`");
385    }
386    Ok(())
387}
388
389fn has_remote_url_scheme(value: &str) -> bool {
390    value.starts_with("http://") || value.starts_with("https://") || value.starts_with("//")
391}
392
393#[cfg(test)]
394mod tests;