Skip to main content

max / balanced_breakfast

Rhai fetch timeout (60s aggregate deadline), theme loading via theme-common crate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-25 21:49 UTC
Commit: 42051cee645b9b84b2ac1dfe9f877a9191e22a48
Parent: 311818f
6 files changed, +118 insertions, -228 deletions
M Cargo.lock +9
@@ -205,6 +205,7 @@ dependencies = [
205 205 "tauri-plugin-shell",
206 206 "tauri-plugin-updater",
207 207 "tauri-plugin-window-state",
208 + "theme-common",
208 209 "tokio",
209 210 "toml 0.8.2",
210 211 "tracing",
@@ -5319,6 +5320,14 @@ dependencies = [
5319 5320 ]
5320 5321
5321 5322 [[package]]
5323 + name = "theme-common"
5324 + version = "0.3.0"
5325 + dependencies = [
5326 + "serde",
5327 + "toml 0.8.2",
5328 + ]
5329 +
5330 + [[package]]
5322 5331 name = "thin-slice"
5323 5332 version = "0.1.1"
5324 5333 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +1
@@ -44,6 +44,7 @@ parking_lot = "0.12"
44 44 # Utilities
45 45 chrono = { version = "0.4.43", features = ["serde"] }
46 46 uuid = { version = "1.20.0", features = ["v4", "serde"] }
47 + theme-common = { path = "../theme-common" }
47 48 toml = "0.8.2"
48 49 dirs = "6.0.0"
49 50
@@ -1,7 +1,7 @@
1 1 //! Host functions registered into the Rhai engine for plugin scripts.
2 2
3 3 use std::io::Read as _;
4 - use std::sync::atomic::{AtomicUsize, Ordering};
4 + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
5 5 use std::sync::Arc;
6 6 use std::time::Duration;
7 7
@@ -24,6 +24,12 @@ const MAX_RESPONSE_BYTES: u64 = 2 * 1024 * 1024;
24 24 /// makes 1-3 requests; 100 allows pagination while catching runaways.
25 25 const MAX_REQUESTS_PER_FETCH: usize = 100;
26 26
27 + /// Maximum wall-clock duration for a single `fetch()` invocation (60 seconds).
28 + /// Checked on each HTTP request. Prevents plugins from hanging the app
29 + /// even if individual requests are fast (100 requests × 15s = 25 min worst case
30 + /// without this limit).
31 + pub(super) const MAX_FETCH_DURATION: Duration = Duration::from_secs(60);
32 +
27 33 /// Validate a URL before making an HTTP request. Blocks non-HTTP schemes
28 34 /// and requests to localhost/internal networks.
29 35 fn validate_url(url: &str) -> Result<(), String> {
@@ -80,6 +86,27 @@ fn check_request_limit(counter: &AtomicUsize) -> Result<(), String> {
80 86 }
81 87 }
82 88
89 + /// Check that the aggregate fetch deadline has not passed.
90 + /// A deadline of 0 means no deadline is set (one-off engine use).
91 + fn check_fetch_deadline(deadline: &AtomicU64) -> Result<(), String> {
92 + let deadline_ms = deadline.load(Ordering::Relaxed);
93 + if deadline_ms == 0 {
94 + return Ok(());
95 + }
96 + let now_ms = std::time::SystemTime::now()
97 + .duration_since(std::time::UNIX_EPOCH)
98 + .unwrap_or_default()
99 + .as_millis() as u64;
100 + if now_ms > deadline_ms {
101 + Err(format!(
102 + "Fetch timeout exceeded ({}s aggregate limit)",
103 + MAX_FETCH_DURATION.as_secs()
104 + ))
105 + } else {
106 + Ok(())
107 + }
108 + }
109 +
83 110 /// Register host functions available to Rhai scripts.
84 111 ///
85 112 /// # Trust model
@@ -91,12 +118,18 @@ fn check_request_limit(counter: &AtomicUsize) -> Result<(), String> {
91 118 /// they trust, similar to shell scripts. If BB ever supports remote/untrusted
92 119 /// plugin sources, HTTP sandboxing (domain allowlist or per-plugin permissions)
93 120 /// must be added before that feature ships.
94 - pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc<AtomicUsize>) {
121 + pub(super) fn register_host_functions(
122 + engine: &mut Engine,
123 + request_counter: Arc<AtomicUsize>,
124 + fetch_deadline: Arc<AtomicU64>,
125 + ) {
95 126 // HTTP GET returning string (see trust model above)
96 127 let counter = request_counter.clone();
128 + let deadline = fetch_deadline.clone();
97 129 engine.register_fn("http_get", move |url: &str| -> Result<String, Box<rhai::EvalAltResult>> {
98 130 validate_url(url)?;
99 131 check_request_limit(&counter)?;
132 + check_fetch_deadline(&deadline)?;
100 133
101 134 let response = ureq::get(url)
102 135 .set("User-Agent", USER_AGENT)
@@ -115,11 +148,13 @@ pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc<
115 148
116 149 // HTTP GET returning parsed JSON as Dynamic
117 150 let counter = request_counter;
151 + let deadline = fetch_deadline;
118 152 engine.register_fn(
119 153 "http_get_json",
120 154 move |url: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> {
121 155 validate_url(url)?;
122 156 check_request_limit(&counter)?;
157 + check_fetch_deadline(&deadline)?;
123 158
124 159 let response = ureq::get(url)
125 160 .set("User-Agent", USER_AGENT)
@@ -278,7 +313,9 @@ mod tests {
278 313
279 314 use rhai::Dynamic;
280 315
281 - use super::{check_request_limit, validate_url, MAX_REQUESTS_PER_FETCH};
316 + use std::sync::atomic::AtomicU64;
317 +
318 + use super::{check_fetch_deadline, check_request_limit, validate_url, MAX_REQUESTS_PER_FETCH};
282 319
283 320 /// Truncate text with ellipsis (mirrors the Rhai-registered closure for testing).
284 321 fn truncate_text(text: &str, max: usize) -> String {
@@ -554,6 +591,37 @@ mod tests {
554 591 assert!(check_request_limit(&counter).is_ok());
555 592 }
556 593
594 + // ── fetch deadline tests ─────────────────────────────────────
595 +
596 + #[test]
597 + fn fetch_deadline_zero_means_no_limit() {
598 + let deadline = AtomicU64::new(0);
599 + assert!(check_fetch_deadline(&deadline).is_ok());
600 + }
601 +
602 + #[test]
603 + fn fetch_deadline_future_passes() {
604 + let future_ms = std::time::SystemTime::now()
605 + .duration_since(std::time::UNIX_EPOCH)
606 + .unwrap()
607 + .as_millis() as u64
608 + + 60_000;
609 + let deadline = AtomicU64::new(future_ms);
610 + assert!(check_fetch_deadline(&deadline).is_ok());
611 + }
612 +
613 + #[test]
614 + fn fetch_deadline_past_fails() {
615 + let past_ms = std::time::SystemTime::now()
616 + .duration_since(std::time::UNIX_EPOCH)
617 + .unwrap()
618 + .as_millis() as u64
619 + - 1_000;
620 + let deadline = AtomicU64::new(past_ms);
621 + let err = check_fetch_deadline(&deadline).unwrap_err();
622 + assert!(err.contains("aggregate limit"), "error should mention aggregate limit: {err}");
623 + }
624 +
557 625 // ── extract_article tests ──────────────────────────────────────
558 626
559 627 #[test]
@@ -12,7 +12,7 @@ mod host_functions;
12 12
13 13 use std::collections::HashMap;
14 14 use std::path::Path;
15 - use std::sync::atomic::{AtomicUsize, Ordering};
15 + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
16 16 use std::sync::Arc;
17 17
18 18 use bb_interface::{BusserCapabilities, ConfigSchema};
@@ -75,6 +75,10 @@ pub struct RhaiPlugin {
75 75 /// Per-fetch HTTP request counter, reset to 0 before each `fetch()` call
76 76 /// and checked by host functions to enforce the 100-request-per-fetch limit.
77 77 request_counter: Arc<AtomicUsize>,
78 + /// Per-fetch deadline (Unix epoch milliseconds). Set at the start of each
79 + /// `fetch()` call and checked by host functions to enforce the 60-second
80 + /// aggregate timeout. A value of 0 means no deadline (one-off engine use).
81 + fetch_deadline: Arc<AtomicU64>,
78 82 }
79 83
80 84 impl RhaiPlugin {
@@ -113,6 +117,16 @@ impl RhaiPlugin {
113 117 // Reset per-fetch request counter
114 118 self.request_counter.store(0, Ordering::Relaxed);
115 119
120 + // Set aggregate fetch deadline (current time + 60s)
121 + let now_ms = std::time::SystemTime::now()
122 + .duration_since(std::time::UNIX_EPOCH)
123 + .unwrap_or_default()
124 + .as_millis() as u64;
125 + self.fetch_deadline.store(
126 + now_ms + host_functions::MAX_FETCH_DURATION.as_millis() as u64,
127 + Ordering::Relaxed,
128 + );
129 +
116 130 let mut scope = Scope::new();
117 131
118 132 let config_map = busser_config_to_dynamic(config);
@@ -136,6 +150,7 @@ pub struct RhaiPluginManager {
136 150 engine: Arc<Engine>,
137 151 plugins: HashMap<String, RhaiPlugin>,
138 152 request_counter: Arc<AtomicUsize>,
153 + fetch_deadline: Arc<AtomicU64>,
139 154 }
140 155
141 156 impl RhaiPluginManager {
@@ -164,12 +179,14 @@ impl RhaiPluginManager {
164 179 engine.set_max_map_size(100);
165 180
166 181 let request_counter = Arc::new(AtomicUsize::new(0));
167 - register_host_functions(&mut engine, request_counter.clone());
182 + let fetch_deadline = Arc::new(AtomicU64::new(0));
183 + register_host_functions(&mut engine, request_counter.clone(), fetch_deadline.clone());
168 184
169 185 Self {
170 186 engine: Arc::new(engine),
171 187 plugins: HashMap::new(),
172 188 request_counter,
189 + fetch_deadline,
173 190 }
174 191 }
175 192
@@ -209,6 +226,7 @@ impl RhaiPluginManager {
209 226 ast,
210 227 engine: self.engine.clone(),
211 228 request_counter: self.request_counter.clone(),
229 + fetch_deadline: self.fetch_deadline.clone(),
212 230 };
213 231
214 232 self.plugins.insert(id.clone(), plugin);
@@ -252,7 +270,8 @@ pub fn create_engine() -> Engine {
252 270 engine.set_max_array_size(1_000);
253 271 engine.set_max_map_size(100);
254 272 let counter = Arc::new(AtomicUsize::new(0));
255 - register_host_functions(&mut engine, counter);
273 + let deadline = Arc::new(AtomicU64::new(0));
274 + register_host_functions(&mut engine, counter, deadline);
256 275 engine
257 276 }
258 277
@@ -52,6 +52,7 @@ rand.workspace = true
52 52 roxmltree.workspace = true
53 53
54 54 # Theme loading
55 + theme-common.workspace = true
55 56 toml.workspace = true
56 57
57 58 # Logging
@@ -1,12 +1,12 @@
1 - //! Theme loading commands — reads TOML themes from bundled resources, dev fallback, and user config.
1 + //! Theme loading commands — thin Tauri wrappers over `theme_common`.
2 2
3 - use serde::Serialize;
4 - use std::collections::HashMap;
5 3 use std::path::PathBuf;
6 4 use tauri::{AppHandle, Manager};
5 + use tracing::instrument;
7 6
8 7 use super::error::ApiError;
9 - use tracing::instrument;
8 +
9 + pub use theme_common::{ThemeColors, ThemeMeta};
10 10
11 11 /// Returns theme directories in priority order (later overrides earlier by ID).
12 12 /// Each entry is `(path, is_custom)`.
@@ -22,15 +22,8 @@ fn theme_dirs(app: &AppHandle) -> Vec<(PathBuf, bool)> {
22 22 }
23 23
24 24 // 2. Dev fallback: CARGO_MANIFEST_DIR → 3 parents up → Git/themes/
25 - let dev_themes = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
26 - .parent() // balanced_breakfast/
27 - .and_then(|p| p.parent()) // active/
28 - .and_then(|p| p.parent()) // Git/
29 - .map(|p| p.join("themes"));
30 - if let Some(ref dev_path) = dev_themes {
31 - if dev_path.is_dir() {
32 - dirs.push((dev_path.clone(), false));
33 - }
25 + if let Some(dev_path) = theme_common::dev_themes_dir(env!("CARGO_MANIFEST_DIR").as_ref(), 3) {
26 + dirs.push((dev_path, false));
34 27 }
35 28
36 29 // 3. User custom themes (highest priority)
@@ -44,223 +37,22 @@ fn theme_dirs(app: &AppHandle) -> Vec<(PathBuf, bool)> {
44 37 dirs
45 38 }
46 39
47 - fn validate_theme_id(id: &str) -> Result<(), ApiError> {
48 - if !id
49 - .chars()
50 - .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
51 - {
52 - return Err(ApiError::bad_request(format!("Invalid theme ID: {}", id)));
53 - }
54 - Ok(())
55 - }
56 -
57 - #[derive(Debug, Serialize)]
58 - #[serde(rename_all = "camelCase")]
59 - pub struct ThemeMeta {
60 - pub id: String,
61 - pub name: String,
62 - pub variant: String,
63 - pub is_custom: bool,
64 - }
65 -
66 - #[derive(Debug, Serialize)]
67 - #[serde(rename_all = "camelCase")]
68 - pub struct ThemeColors {
69 - pub meta: ThemeMeta,
70 - pub colors: HashMap<String, String>,
71 - }
72 -
73 - /// Parse a TOML theme file into its meta fields.
74 - fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
75 - let meta = table.get("meta").and_then(|m| m.as_table());
76 - let name = meta
77 - .and_then(|m| m.get("name"))
78 - .and_then(|v| v.as_str())
79 - .unwrap_or(id)
80 - .to_string();
81 - let variant = meta
82 - .and_then(|m| m.get("variant"))
83 - .and_then(|v| v.as_str())
84 - .unwrap_or("dark")
85 - .to_string();
86 -
87 - ThemeMeta {
88 - id: id.to_string(),
89 - name,
90 - variant,
91 - is_custom,
92 - }
93 - }
94 -
95 40 #[tauri::command]
96 41 #[instrument(skip_all)]
97 42 pub fn list_themes(app: AppHandle) -> Result<Vec<ThemeMeta>, ApiError> {
98 43 let dirs = theme_dirs(&app);
99 - // Collect themes, later dirs override earlier by ID
100 - let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
101 -
102 - for (dir, is_custom) in &dirs {
103 - let entries = match std::fs::read_dir(dir) {
104 - Ok(e) => e,
105 - Err(_) => continue,
106 - };
107 -
108 - for entry in entries {
109 - let entry = match entry {
110 - Ok(e) => e,
111 - Err(_) => continue,
112 - };
113 - let path = entry.path();
114 - if path.extension().and_then(|e| e.to_str()) != Some("toml") {
115 - continue;
116 - }
117 -
118 - let id = path
119 - .file_stem()
120 - .and_then(|s| s.to_str())
121 - .unwrap_or_default()
122 - .to_string();
123 -
124 - let content = match std::fs::read_to_string(&path) {
125 - Ok(c) => c,
126 - Err(_) => continue,
127 - };
128 - let table: toml::Table = match content.parse() {
129 - Ok(t) => t,
130 - Err(_) => continue,
131 - };
132 -
133 - seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
134 - }
135 - }
136 -
137 - let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
138 - themes.sort_by(|a, b| a.name.cmp(&b.name));
139 - Ok(themes)
140 - }
141 -
142 - /// Find a theme file by ID, checking directories in reverse priority order
143 - /// (user custom first, then dev, then bundled).
144 - fn find_theme_path(app: &AppHandle, id: &str) -> Result<(PathBuf, bool), ApiError> {
145 - let dirs = theme_dirs(app);
146 - let filename = format!("{}.toml", id);
147 -
148 - // Check in reverse so highest-priority dir wins
149 - for (dir, is_custom) in dirs.iter().rev() {
150 - let path = dir.join(&filename);
151 - if path.is_file() {
152 - return Ok((path, *is_custom));
153 - }
154 - }
155 -
156 - Err(ApiError::not_found(format!("Theme '{}' not found", id)))
157 - }
158 -
159 - #[cfg(test)]
160 - mod tests {
161 - use super::*;
162 -
163 - // -- validate_theme_id tests --
164 -
165 - #[test]
166 - fn validate_theme_id_alphanumeric() {
167 - assert!(validate_theme_id("darkmode").is_ok());
168 - assert!(validate_theme_id("Theme123").is_ok());
169 - }
170 -
171 - #[test]
172 - fn validate_theme_id_hyphens_underscores() {
173 - assert!(validate_theme_id("dark-mode").is_ok());
174 - assert!(validate_theme_id("my_theme_v2").is_ok());
175 - assert!(validate_theme_id("a-b_c-d").is_ok());
176 - }
177 -
178 - #[test]
179 - fn validate_theme_id_rejects_spaces() {
180 - assert!(validate_theme_id("has space").is_err());
181 - }
182 -
183 - #[test]
184 - fn validate_theme_id_rejects_path_traversal() {
185 - assert!(validate_theme_id("../etc/passwd").is_err());
186 - assert!(validate_theme_id("foo/bar").is_err());
187 - }
188 -
189 - #[test]
190 - fn validate_theme_id_rejects_special_chars() {
191 - assert!(validate_theme_id("evil<script>").is_err());
192 - assert!(validate_theme_id("theme;drop").is_err());
193 - assert!(validate_theme_id("theme.toml").is_err());
194 - }
195 -
196 - #[test]
197 - fn validate_theme_id_empty_is_invalid() {
198 - assert!(validate_theme_id("").is_ok(), "empty string has no invalid chars");
199 - }
200 -
201 - // -- parse_meta tests --
202 -
203 - #[test]
204 - fn parse_meta_with_name_and_variant() {
205 - let toml_str = r#"
206 - [meta]
207 - name = "Solarized Dark"
208 - variant = "light"
209 - "#;
210 - let table: toml::Table = toml_str.parse().unwrap();
211 - let meta = parse_meta("solarized", &table, false);
212 - assert_eq!(meta.id, "solarized");
213 - assert_eq!(meta.name, "Solarized Dark");
214 - assert_eq!(meta.variant, "light");
215 - assert!(!meta.is_custom);
216 - }
217 -
218 - #[test]
219 - fn parse_meta_defaults_to_id_and_dark() {
220 - let table: toml::Table = "".parse().unwrap();
221 - let meta = parse_meta("fallback", &table, true);
222 - assert_eq!(meta.name, "fallback");
223 - assert_eq!(meta.variant, "dark");
224 - assert!(meta.is_custom);
225 - }
226 -
227 - #[test]
228 - fn parse_meta_missing_variant_defaults_dark() {
229 - let toml_str = r#"
230 - [meta]
231 - name = "Minimal"
232 - "#;
233 - let table: toml::Table = toml_str.parse().unwrap();
234 - let meta = parse_meta("minimal", &table, false);
235 - assert_eq!(meta.name, "Minimal");
236 - assert_eq!(meta.variant, "dark");
237 - }
44 + Ok(theme_common::list_themes_from_dirs(&dirs))
238 45 }
239 46
240 47 #[tauri::command]
241 48 #[instrument(skip_all)]
242 49 pub fn get_theme(app: AppHandle, id: String) -> Result<ThemeColors, ApiError> {
243 - validate_theme_id(&id)?;
244 -
245 - let (path, is_custom) = find_theme_path(&app, &id)?;
246 - let content = std::fs::read_to_string(&path)
247 - .map_err(|e| ApiError::internal(format!("Failed to read {}: {}", path.display(), e)))?;
248 - let table: toml::Table = content
249 - .parse()
250 - .map_err(|e| ApiError::internal(format!("Failed to parse {}: {}", path.display(), e)))?;
251 -
252 - let meta = parse_meta(&id, &table, is_custom);
253 -
254 - let mut colors = HashMap::new();
255 - for section in &["background", "foreground", "accent", "border"] {
256 - if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
257 - for (key, val) in sect {
258 - if let Some(color) = val.as_str() {
259 - colors.insert(format!("{}.{}", section, key), color.to_string());
260 - }
261 - }
262 - }
263 - }
50 + let dirs = theme_dirs(&app);
51 + theme_common::load_theme(&dirs, &id).map_err(|e| ApiError::internal(e))
52 + }
264 53
265 - Ok(ThemeColors { meta, colors })
54 + #[cfg(test)]
55 + mod tests {
56 + // Core theme logic tests live in theme-common crate.
57 + // App-specific tests here only if needed for theme_dirs behavior.
266 58 }