|
5 | 5 | // there by design. |
6 | 6 | #![cfg_attr(not(feature = "desktop"), allow(dead_code))] |
7 | 7 |
|
8 | | -use std::collections::HashMap; |
| 8 | +use std::collections::{HashMap, HashSet}; |
9 | 9 | use std::path::PathBuf; |
10 | 10 | #[cfg(target_os = "macos")] |
11 | 11 | use std::process::Command; |
@@ -63,6 +63,75 @@ pub struct AuthSession { |
63 | 63 | pub user_id: String, |
64 | 64 | } |
65 | 65 |
|
| 66 | +fn validate_sidebar_order(order: &crate::settings::SidebarOrder) -> Result<(), String> { |
| 67 | + const MAX_ITEMS: usize = 8192; |
| 68 | + const MAX_CONNECTIONS: usize = 256; |
| 69 | + const MAX_ROOT_BYTES: usize = 32768; |
| 70 | + const MAX_TASK_ID_BYTES: usize = 256; |
| 71 | + const MAX_ORDER_BYTES: usize = 2 * 1024 * 1024; |
| 72 | + if order.projects.len() > MAX_ITEMS |
| 73 | + || order.tasks.len() > MAX_ITEMS |
| 74 | + || order.pinned_tasks.len() > MAX_ITEMS |
| 75 | + || order.connections.len() > MAX_CONNECTIONS |
| 76 | + { |
| 77 | + return Err("Sidebar order is too large".into()); |
| 78 | + } |
| 79 | + if order.created_before_ms < 0 { |
| 80 | + return Err("Invalid sidebar order cutoff".into()); |
| 81 | + } |
| 82 | + let mut bytes = 0_usize; |
| 83 | + let mut validate_computer = |computer: &Option<String>| -> Result<(), String> { |
| 84 | + if let Some(computer) = computer { |
| 85 | + if computer.is_empty() || computer.len() > 128 || computer.chars().any(char::is_control) |
| 86 | + { |
| 87 | + return Err("Invalid sidebar computer".into()); |
| 88 | + } |
| 89 | + bytes = bytes.saturating_add(computer.len()); |
| 90 | + } |
| 91 | + Ok(()) |
| 92 | + }; |
| 93 | + for (items, maximum, is_task) in [ |
| 94 | + (&order.projects, MAX_ROOT_BYTES, false), |
| 95 | + (&order.tasks, MAX_TASK_ID_BYTES, true), |
| 96 | + (&order.pinned_tasks, MAX_TASK_ID_BYTES, true), |
| 97 | + ] { |
| 98 | + let mut seen = HashSet::new(); |
| 99 | + for item in items { |
| 100 | + validate_computer(&item.computer)?; |
| 101 | + if item.id.is_empty() |
| 102 | + || item.id.len() > maximum |
| 103 | + || item.id.contains('\0') |
| 104 | + || (is_task && item.id.chars().any(char::is_control)) |
| 105 | + || !seen.insert(item) |
| 106 | + { |
| 107 | + return Err("Invalid sidebar order item".into()); |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + let mut connections = HashSet::new(); |
| 112 | + for computer in &order.connections { |
| 113 | + validate_computer(computer)?; |
| 114 | + if !connections.insert(computer) { |
| 115 | + return Err("Duplicate sidebar connection".into()); |
| 116 | + } |
| 117 | + } |
| 118 | + bytes = order |
| 119 | + .projects |
| 120 | + .iter() |
| 121 | + .chain(&order.tasks) |
| 122 | + .chain(&order.pinned_tasks) |
| 123 | + .fold(bytes, |bytes, item| bytes.saturating_add(item.id.len())); |
| 124 | + if bytes > MAX_ORDER_BYTES |
| 125 | + || serde_json::to_vec(order) |
| 126 | + .map_err(|_| "Could not encode sidebar order".to_string())? |
| 127 | + .len() |
| 128 | + > MAX_ORDER_BYTES |
| 129 | + { |
| 130 | + return Err("Sidebar order is too large".into()); |
| 131 | + } |
| 132 | + Ok(()) |
| 133 | +} |
| 134 | + |
66 | 135 | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] |
67 | 136 | #[serde(rename_all = "camelCase")] |
68 | 137 | pub struct SelectedProject { |
@@ -1312,6 +1381,54 @@ impl LocalBackend { |
1312 | 1381 | .map_err(|_| "Could not save remote sidebar settings".to_string())? |
1313 | 1382 | } |
1314 | 1383 |
|
| 1384 | + /// Sidebar order is account-owned controller presentation state, independent |
| 1385 | + /// of the execution host's project ordering and navigation cursors. |
| 1386 | + pub async fn get_sidebar_order( |
| 1387 | + &self, |
| 1388 | + user_id: &str, |
| 1389 | + ) -> Result<crate::settings::SidebarOrder, String> { |
| 1390 | + let (store, account) = self.presentation_account(user_id)?; |
| 1391 | + tokio::task::spawn_blocking(move || { |
| 1392 | + let order = match store |
| 1393 | + .account_setting(account, "sidebar.order") |
| 1394 | + .map_err(|error| error.to_string())? |
| 1395 | + { |
| 1396 | + Some(value) => serde_json::from_value(value) |
| 1397 | + .map_err(|_| "Saved sidebar order is invalid".to_string())?, |
| 1398 | + None => crate::settings::SidebarOrder::default(), |
| 1399 | + }; |
| 1400 | + validate_sidebar_order(&order)?; |
| 1401 | + Ok(order) |
| 1402 | + }) |
| 1403 | + .await |
| 1404 | + .map_err(|_| "Could not read sidebar order".to_string())? |
| 1405 | + } |
| 1406 | + |
| 1407 | + pub async fn set_sidebar_order( |
| 1408 | + &self, |
| 1409 | + user_id: &str, |
| 1410 | + order: crate::settings::SidebarOrder, |
| 1411 | + ) -> Result<(), String> { |
| 1412 | + validate_sidebar_order(&order)?; |
| 1413 | + // Host dispatch already holds the account read gate. Reuse the |
| 1414 | + // presentation writer gate without taking a nested account read lock. |
| 1415 | + let _gate = self.ui_settings_gate.lock().await; |
| 1416 | + let (store, account) = self.presentation_account(user_id)?; |
| 1417 | + tokio::task::spawn_blocking(move || { |
| 1418 | + store |
| 1419 | + .set_account_setting( |
| 1420 | + account, |
| 1421 | + "sidebar.order", |
| 1422 | + &serde_json::to_value(order) |
| 1423 | + .map_err(|_| "Could not encode sidebar order".to_string())?, |
| 1424 | + crate::settings::timestamp_ms(), |
| 1425 | + ) |
| 1426 | + .map_err(|error| error.to_string()) |
| 1427 | + }) |
| 1428 | + .await |
| 1429 | + .map_err(|_| "Could not save sidebar order".to_string())? |
| 1430 | + } |
| 1431 | + |
1315 | 1432 | pub async fn set_task_pinned( |
1316 | 1433 | &self, |
1317 | 1434 | user_id: &str, |
|
0 commit comments