mesh_llm_plugin/manifest/
web_ui.rs1use 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;