1use std::collections::BTreeMap;
14use std::io::Read;
15use std::path::{Component, Path};
16
17use serde::{Deserialize, Serialize};
18
19use super::widget_packages::{self as wp, Manifest, WidgetKind, WidgetPackageInfo, WidgetState};
20use super::widget_signature::{self as sig, WidgetSignatureInfo};
21
22pub const WIDGETS_DIR: &str = "widgets";
24pub const MAX_BUNDLE_WIDGETS: usize = 20;
26pub const MAX_BUNDLE_WIDGET_BYTES: u64 = 100 * 1024 * 1024;
30
31pub fn is_widget_path(path: &str) -> bool {
33 path.strip_prefix(WIDGETS_DIR).is_some_and(|rest| rest.starts_with('/'))
34}
35
36pub fn is_widget_warning(warning: &str) -> bool {
39 is_widget_path(warning) || warning.split_once('/').is_some_and(|(_, rest)| is_widget_path(rest))
40}
41
42#[derive(Debug, Clone, Default)]
44pub struct BundleWidget {
45 pub id: String,
46 pub files: BTreeMap<String, Vec<u8>>,
48}
49
50pub fn read_from_zip(zip_bytes: &[u8]) -> Result<Vec<BundleWidget>, String> {
54 let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| format!("not a valid zip archive: {e}"))?;
55 let names: Vec<String> = (0..archive.len())
56 .filter_map(|i| archive.by_index(i).ok().filter(|e| !e.is_dir()).map(|e| e.name().to_string()))
57 .collect();
58 let wrapper = names.first().and_then(|n| n.split_once('/').map(|(w, _)| w.to_string())).filter(|w| {
61 names.iter().all(|n| n.split_once('/').is_some_and(|(r, _)| r == w))
62 });
63 let mut out: BTreeMap<String, BundleWidget> = BTreeMap::new();
64 let mut total: u64 = 0;
65 for i in 0..archive.len() {
66 let mut entry = archive.by_index(i).map_err(|e| format!("zip archive: entry {i}: {e}"))?;
67 if entry.is_dir() {
68 continue;
69 }
70 let name = entry.name().to_string();
71 let rel = match &wrapper {
72 Some(w) => name.strip_prefix(w.as_str()).and_then(|r| r.strip_prefix('/')).unwrap_or(&name).to_string(),
73 None => name.clone(),
74 };
75 let Some(rest) = rel.strip_prefix(WIDGETS_DIR).and_then(|r| r.strip_prefix('/')) else { continue };
76 let Some((id, path)) = rest.split_once('/') else {
77 return Err(format!("{name}: a widget's files go in widgets/<id>/"));
78 };
79 if !wp::valid_id(id) {
80 return Err(format!("{name}: {id:?} isn't a widget id (publisher.name)"));
81 }
82 if path.is_empty() || path.contains('\\') || !Path::new(path).components().all(|c| matches!(c, Component::Normal(_))) {
83 return Err(format!("{name}: not a path inside the widget"));
84 }
85 if entry.unix_mode().is_some_and(|m| m & 0o170000 == 0o120000) {
86 return Err(format!("{name} is a link; a widget can't contain links"));
87 }
88 if !out.contains_key(id) && out.len() >= MAX_BUNDLE_WIDGETS {
89 return Err(format!("the bundle carries more than {MAX_BUNDLE_WIDGETS} widgets"));
90 }
91 let w = out.entry(id.to_string()).or_insert_with(|| BundleWidget { id: id.to_string(), ..Default::default() });
92 let used: u64 = w.files.values().map(|b| b.len() as u64).sum();
93 let room = (wp::MAX_PACKAGE_BYTES - used).min(MAX_BUNDLE_WIDGET_BYTES - total);
96 let mut bytes = Vec::new();
97 (&mut entry).take(room + 1).read_to_end(&mut bytes).map_err(|e| format!("{name}: {e}"))?;
98 if bytes.len() as u64 > room {
99 return Err(if used + bytes.len() as u64 > wp::MAX_PACKAGE_BYTES {
100 format!("widget {id} is over {} MB", wp::MAX_PACKAGE_BYTES / (1024 * 1024))
101 } else {
102 format!("the bundle's widgets are over {} MB in all", MAX_BUNDLE_WIDGET_BYTES / (1024 * 1024))
103 });
104 }
105 total += bytes.len() as u64;
106 w.files.insert(path.to_string(), bytes);
107 if w.files.len() > wp::MAX_PACKAGE_FILES {
108 return Err(format!("widget {id} has over {} files", wp::MAX_PACKAGE_FILES));
109 }
110 }
111 Ok(out.into_values().collect())
112}
113
114pub fn stage(w: &BundleWidget) -> Result<(tempfile::TempDir, std::path::PathBuf), String> {
117 let tmp = tempfile::tempdir().map_err(|e| format!("can't make a staging folder: {e}"))?;
118 let root = tmp.path().join(&w.id);
119 for (path, bytes) in &w.files {
120 let full = root.join(path);
121 if let Some(dir) = full.parent() {
122 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
123 }
124 std::fs::write(&full, bytes).map_err(|e| format!("can't stage {path}: {e}"))?;
125 }
126 Ok((tmp, root))
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
131#[ts(export, export_to = "../../../frontend/types/rpc/")]
132pub struct BundleImportWidgetPreview {
133 pub id: String,
134 pub name: String,
135 pub version: String,
136 pub description: Option<String>,
137 pub author: Option<String>,
138 pub kind: WidgetKind,
139 pub permissions: Vec<String>,
140 pub hash: String,
141 pub signature: Option<WidgetSignatureInfo>,
142 pub installed_version: Option<String>,
144 pub installed_state: Option<WidgetState>,
145 pub same_as_installed: bool,
147 pub error: Option<String>,
149}
150
151pub fn declared(manifest_json: Option<&str>) -> BTreeMap<String, String> {
153 #[derive(Deserialize)]
154 struct Entry {
155 id: String,
156 #[serde(default)]
157 hash: String,
158 }
159 manifest_json
160 .and_then(|t| serde_json::from_str::<serde_json::Value>(t).ok())
161 .and_then(|v| v.pointer("/components/widgets").cloned())
162 .and_then(|v| serde_json::from_value::<Vec<Entry>>(v).ok())
163 .unwrap_or_default()
164 .into_iter()
165 .map(|e| (e.id, e.hash))
166 .collect()
167}
168
169pub fn preview(
172 w: &BundleWidget,
173 declared: &BTreeMap<String, String>,
174 installed: &[WidgetPackageInfo],
175 describe_signature: impl Fn(&str, Option<&str>) -> WidgetSignatureInfo,
176) -> BundleImportWidgetPreview {
177 let mut p = BundleImportWidgetPreview {
178 id: w.id.clone(),
179 name: w.id.clone(),
180 version: String::new(),
181 description: None,
182 author: None,
183 kind: WidgetKind::Sandboxed,
184 permissions: vec![],
185 hash: String::new(),
186 signature: None,
187 installed_version: None,
188 installed_state: None,
189 same_as_installed: false,
190 error: None,
191 };
192 let checked = (|| -> Result<(), String> {
193 let (_tmp, root) = stage(w)?;
194 let m: Manifest = serde_json::from_slice(w.files.get(wp::MANIFEST_FILE).ok_or("it has no widget.json")?)
195 .map_err(|e| format!("widget.json: {e}"))?;
196 wp::validate(&m, &w.id)?;
197 p.name = m.name.clone();
198 p.version = m.version.clone();
199 p.description = m.description.clone();
200 p.author = m.author.clone();
201 p.kind = m.kind();
202 p.permissions = m.permissions.clone();
203 let hash = wp::package_hash(&wp::hash_files(&root)?);
204 p.hash = hash.clone();
205 if m.kind() != WidgetKind::Sandboxed {
206 return Err("bundles can carry sandboxed widgets only".to_string());
207 }
208 match declared.get(&w.id) {
209 None => return Err("bundle.json doesn't list it in components.widgets".to_string()),
210 Some(h) if *h != hash => return Err("its files don't match the hash bundle.json lists for it".to_string()),
211 _ => {}
212 }
213 let signer = match sig::check(&root, &m.id, &m.version, &hash) {
214 None => None,
215 Some(Ok(k)) => Some(k),
216 Some(Err(e)) => return Err(e),
217 };
218 p.signature = Some(describe_signature(&m.id, signer.as_deref()));
219 Ok(())
220 })();
221 if let Some(i) = installed.iter().find(|i| i.id == w.id) {
222 p.installed_version = Some(i.version.clone());
223 p.installed_state = Some(i.state.clone());
224 p.same_as_installed = !p.hash.is_empty()
227 && i.state == WidgetState::Approved
228 && i.hash == p.hash
229 && i.signature.fingerprint == p.signature.as_ref().and_then(|s| s.fingerprint.clone());
230 }
231 p.error = checked.err();
232 p
233}
234
235pub fn install(widgets_dir: &Path, w: &BundleWidget) -> Result<String, String> {
237 let (_tmp, root) = stage(w)?;
238 wp::install(widgets_dir, &root, true)
239}
240
241pub fn zip_export_with_widgets(
245 export: &super::bundle_export::BundleExport,
246 widgets: &[(String, String, BTreeMap<String, Vec<u8>>)],
247) -> Result<Vec<u8>, String> {
248 use std::io::Write;
249 use zip::write::SimpleFileOptions;
250 if widgets.len() > MAX_BUNDLE_WIDGETS {
252 return Err(format!("a bundle carries at most {MAX_BUNDLE_WIDGETS} widgets"));
253 }
254 let total: u64 = widgets.iter().flat_map(|(_, _, files)| files.values()).map(|b| b.len() as u64).sum();
255 if total > MAX_BUNDLE_WIDGET_BYTES {
256 return Err(format!("a bundle's widgets are at most {} MB in all", MAX_BUNDLE_WIDGET_BYTES / (1024 * 1024)));
257 }
258 let mut buf = std::io::Cursor::new(Vec::new());
259 let mut writer = zip::ZipWriter::new(&mut buf);
260 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
261 let listed: Vec<serde_json::Value> = widgets
262 .iter()
263 .map(|(id, hash, files)| {
264 let version = files
265 .get(wp::MANIFEST_FILE)
266 .and_then(|m| serde_json::from_slice::<serde_json::Value>(m).ok())
267 .and_then(|m| m.get("version").and_then(|v| v.as_str()).map(str::to_string))
268 .unwrap_or_default();
269 serde_json::json!({ "id": id, "version": version, "hash": hash })
270 })
271 .collect();
272 let mut put = |path: String, bytes: &[u8]| -> Result<(), String> {
273 writer.start_file(path, options).map_err(|e| format!("zip: {e}"))?;
274 writer.write_all(bytes).map_err(|e| format!("zip: {e}"))
275 };
276 for file in &export.files {
277 let path = format!("{}/{}", export.root_slug, file.path);
278 if file.path == super::bundle_import::MANIFEST_FILE {
279 let mut manifest: serde_json::Value = serde_json::from_str(&file.content).map_err(|e| format!("bundle.json: {e}"))?;
280 manifest["components"]["widgets"] = serde_json::Value::Array(listed.clone());
281 put(path, serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?.as_bytes())?;
282 } else {
283 put(path, file.content.as_bytes())?;
284 }
285 }
286 for (id, _, files) in widgets {
287 for (path, bytes) in files {
288 put(format!("{}/{WIDGETS_DIR}/{id}/{path}", export.root_slug), bytes)?;
289 }
290 }
291 drop(put);
292 writer.finish().map_err(|e| format!("zip: {e}"))?;
293 Ok(buf.into_inner())
294}
295
296#[cfg(test)]
297#[path = "bundle_widgets_tests.rs"]
298mod tests;