1use serde_json::{Map, Value};
6
7use super::{GUARD_KINDS, ProError, ProResult, plain_text};
8
9pub(crate) const MAX_REQUEST_BYTES: usize = 64 * 1024;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Op {
14 SecurityReport,
15 MemoryAnalyze,
16 MemoryCompare,
17 AdaptiveState,
18 ReportBuild,
19 LedgerBrowse,
20 LedgerExport,
21 SpendTrend,
22 WorkAnalysis,
23 AlertsUpdate,
24 AlertsTest,
25 AlertsEvaluate,
27 Profiles,
28 MemorySnapshot,
29 MemoryRestore,
30 MemoryForget,
31 MemoryConsolidate,
32 AdaptiveApply,
33 AdaptiveDismiss,
34}
35
36const OPS: [(&str, Op); 19] = [
37 ("security.report", Op::SecurityReport),
38 ("memory.analyze", Op::MemoryAnalyze),
39 ("memory.compare", Op::MemoryCompare),
40 ("adaptive.state", Op::AdaptiveState),
41 ("report.build", Op::ReportBuild),
42 ("ledger.browse", Op::LedgerBrowse),
43 ("ledger.export", Op::LedgerExport),
44 ("spend.trend", Op::SpendTrend),
45 ("work.analysis", Op::WorkAnalysis),
46 ("alerts.update", Op::AlertsUpdate),
47 ("alerts.test", Op::AlertsTest),
48 ("alerts.evaluate", Op::AlertsEvaluate),
49 ("profiles", Op::Profiles),
50 ("memory.snapshot", Op::MemorySnapshot),
51 ("memory.restore", Op::MemoryRestore),
52 ("memory.forget", Op::MemoryForget),
53 ("memory.consolidate", Op::MemoryConsolidate),
54 ("adaptive.apply", Op::AdaptiveApply),
55 ("adaptive.dismiss", Op::AdaptiveDismiss),
56];
57
58impl Op {
59 pub fn parse(name: &str) -> Option<Self> {
60 OPS.iter().find(|(n, _)| *n == name).map(|(_, op)| *op)
61 }
62
63 pub fn device_wide(self) -> bool {
67 matches!(
68 self,
69 Self::SecurityReport
70 | Self::ReportBuild
71 | Self::LedgerBrowse
72 | Self::LedgerExport
73 | Self::SpendTrend
74 | Self::AlertsUpdate
75 | Self::AlertsTest
76 | Self::AlertsEvaluate
77 | Self::Profiles
78 )
79 }
80
81 pub(crate) fn name(self) -> &'static str {
82 OPS.iter()
83 .find(|(_, op)| *op == self)
84 .map_or("", |(name, _)| name)
85 }
86
87 pub(crate) fn is_write(self) -> bool {
88 matches!(
89 self,
90 Self::AlertsUpdate
91 | Self::AlertsTest
92 | Self::AlertsEvaluate
93 | Self::Profiles
94 | Self::MemorySnapshot
95 | Self::MemoryRestore
96 | Self::MemoryForget
97 | Self::MemoryConsolidate
98 | Self::AdaptiveApply
99 | Self::AdaptiveDismiss
100 )
101 }
102
103 pub(crate) fn allowed_effects(self) -> &'static [&'static str] {
105 match self {
106 Self::AlertsUpdate => &["alerts.settings"],
107 Self::AlertsTest | Self::AlertsEvaluate => &["alerts.notify"],
108 Self::Profiles => &["profiles.put", "profiles.delete", "config.set"],
109 Self::MemorySnapshot => &["memory.snapshot"],
110 Self::MemoryRestore => &["memory.restore"],
111 Self::MemoryForget => &["memory.forget"],
112 Self::MemoryConsolidate => &["memory.consolidate"],
113 Self::AdaptiveApply => &["context.overlay", "adaptive.record"],
114 Self::AdaptiveDismiss => &["adaptive.dismiss"],
115 Self::LedgerExport => &["ledger.export"],
116 _ => &[],
117 }
118 }
119}
120
121#[derive(Debug, Clone)]
123pub(crate) struct BridgeRequest {
124 pub(crate) op: Op,
125 pub(crate) query: Map<String, Value>,
127 pub(crate) body: Map<String, Value>,
128}
129
130impl BridgeRequest {
131 pub(crate) fn q(&self, key: &str) -> &str {
132 self.query.get(key).and_then(Value::as_str).unwrap_or("")
133 }
134 pub(crate) fn b_str(&self, key: &str) -> &str {
135 self.body.get(key).and_then(Value::as_str).unwrap_or("")
136 }
137 pub(crate) fn b_true(&self, key: &str) -> bool {
138 self.body.get(key) == Some(&Value::Bool(true))
139 }
140}
141
142pub(crate) fn parse(raw: &str) -> ProResult<BridgeRequest> {
144 if raw.len() > MAX_REQUEST_BYTES {
145 return Err(ProError::new(413, "Request too large"));
146 }
147 let value: Value =
148 serde_json::from_str(raw).map_err(|_| ProError::bad("Invalid JSON request"))?;
149 let Value::Object(object) = value else {
150 return Err(ProError::bad("JSON object required"));
151 };
152 from_object(object)
153}
154
155pub(crate) fn from_object(mut object: Map<String, Value>) -> ProResult<BridgeRequest> {
157 let op = object
158 .remove("op")
159 .and_then(|v| v.as_str().and_then(Op::parse))
160 .ok_or_else(|| ProError::bad("Unknown analysis operation"))?;
161 let method = object.remove("method");
162 let expected = if op.is_write() { "POST" } else { "GET" };
163 if method.as_ref().and_then(Value::as_str) != Some(expected) {
164 return Err(ProError::bad("Unsupported method for this operation"));
165 }
166 let query = take_object(&mut object, "query")?;
167 let body = take_object(&mut object, "body")?;
168 if !object.is_empty() {
169 return Err(ProError::bad("Unknown request field"));
170 }
171 validate(op, query, &body)
172}
173
174fn take_object(object: &mut Map<String, Value>, key: &str) -> ProResult<Map<String, Value>> {
175 match object.remove(key) {
176 None | Some(Value::Null) => Ok(Map::new()),
177 Some(Value::Object(map)) => Ok(map),
178 Some(_) => Err(ProError::bad(format!("{key} must be an object"))),
179 }
180}
181
182pub(crate) fn validate(
184 op: Op,
185 query: Map<String, Value>,
186 body: &Map<String, Value>,
187) -> ProResult<BridgeRequest> {
188 let query = query_strings(query)?;
189 if op.is_write() && !query.is_empty() {
190 return Err(ProError::bad("This operation takes no query"));
191 }
192 if !op.is_write() && !body.is_empty() {
193 return Err(ProError::bad("This operation takes no body"));
194 }
195 let mut request = BridgeRequest {
196 op,
197 query: Map::new(),
198 body: Map::new(),
199 };
200 match op {
201 Op::SecurityReport => {
202 only(&query, &["days"])?;
203 let days = text(&query, "days", "30");
204 if !["7", "30", "90", "365", "all"].contains(&days.as_str()) {
205 return Err(ProError::bad("Unsupported period"));
206 }
207 request.query.insert("days".into(), days.into());
208 }
209 Op::MemoryCompare => {
210 only(&query, &["id"])?;
211 let id = text(&query, "id", "");
212 if !super::snapshots::is_snapshot_id(&id) {
213 return Err(ProError::bad("Unknown snapshot"));
214 }
215 request.query.insert("id".into(), id.into());
216 }
217 Op::ReportBuild => {
218 only(&query, &["period", "month"])?;
219 let period = text(&query, "period", "this_month");
220 let month = text(&query, "month", "");
221 if !["this_month", "last_month", "last_30", "month"].contains(&period.as_str()) {
222 return Err(ProError::bad("Unsupported report period"));
223 }
224 if period == "month" {
225 if !is_month(&month) {
226 return Err(ProError::bad("Use the month as YYYY-MM"));
227 }
228 } else if !month.is_empty() {
229 return Err(ProError::bad("A month only goes with the month period"));
230 }
231 request.query.insert("period".into(), period.into());
232 request.query.insert("month".into(), month.into());
233 }
234 Op::LedgerBrowse | Op::LedgerExport => {
235 ledger_query(op, &query, &mut request.query)?;
236 }
237 Op::MemoryAnalyze | Op::AdaptiveState | Op::SpendTrend | Op::WorkAnalysis => {
238 only(&query, &[])?;
239 }
240 Op::AlertsUpdate => alerts_body(body, &mut request.body)?,
241 Op::AlertsTest | Op::AlertsEvaluate => only(body, &[])?,
242 Op::Profiles => {
243 only(body, &["action", "name", "confirm_weaker"])?;
244 let action = body.get("action").and_then(Value::as_str).unwrap_or("");
245 if !["save", "apply", "delete"].contains(&action) {
246 return Err(ProError::bad("Unsupported profile action"));
247 }
248 let name = body
249 .get("name")
250 .and_then(Value::as_str)
251 .map(str::trim)
252 .unwrap_or("");
253 if !is_profile_name(name) {
254 return Err(ProError::bad(
255 "Name the profile with letters, numbers, spaces, dots or dashes",
256 ));
257 }
258 request.body.insert("action".into(), action.into());
259 request.body.insert("name".into(), name.into());
260 request.body.insert(
261 "confirm_weaker".into(),
262 flag(body, "confirm_weaker")?.into(),
263 );
264 }
265 Op::MemorySnapshot => {
266 only(body, &["reason"])?;
267 let reason = reason(body, "Manual snapshot")?;
268 request.body.insert("reason".into(), reason.into());
269 }
270 Op::MemoryRestore => {
271 only(body, &["id", "confirm"])?;
272 let id = body.get("id").and_then(Value::as_str).unwrap_or("");
273 if !super::snapshots::is_snapshot_id(id) {
274 return Err(ProError::bad("Unknown snapshot"));
275 }
276 if body.get("confirm") != Some(&Value::Bool(true)) {
277 return Err(ProError::conflict("Confirm restoring this snapshot"));
278 }
279 request.body.insert("id".into(), id.into());
280 request.body.insert("confirm".into(), true.into());
281 }
282 Op::MemoryForget => {
283 only(body, &["refs", "reason"])?;
284 let refs = body
285 .get("refs")
286 .and_then(Value::as_array)
287 .filter(|refs| (1..=100).contains(&refs.len()))
288 .ok_or_else(|| ProError::bad("Choose up to 100 insights"))?;
289 let mut checked = Vec::with_capacity(refs.len());
290 for item in refs {
291 let item = item
292 .as_str()
293 .filter(|r| is_fact_ref(r))
294 .ok_or_else(|| ProError::bad("Invalid insight key"))?;
295 if !checked.contains(&item) {
296 checked.push(item);
297 }
298 }
299 let reason = reason(body, "Before cleanup")?;
300 request.body.insert("refs".into(), checked.into());
301 request.body.insert("reason".into(), reason.into());
302 }
303 Op::MemoryConsolidate => {
304 only(body, &["run"])?;
305 request.body.insert("run".into(), flag(body, "run")?.into());
306 }
307 Op::AdaptiveApply => {
308 only(body, &["path", "action"])?;
309 let action = body.get("action").and_then(Value::as_str).unwrap_or("");
310 if !["exclude", "compress"].contains(&action) {
311 return Err(ProError::bad("Unsupported recommendation"));
312 }
313 request.body.insert("path".into(), path(body)?.into());
314 request.body.insert("action".into(), action.into());
315 }
316 Op::AdaptiveDismiss => {
317 only(body, &["path"])?;
318 request.body.insert("path".into(), path(body)?.into());
319 }
320 }
321 Ok(request)
322}
323
324fn query_strings(query: Map<String, Value>) -> ProResult<Map<String, Value>> {
325 let mut out = Map::new();
326 for (key, value) in query {
327 let value = match value {
328 Value::String(text) => text,
329 Value::Number(number) => number.to_string(),
330 Value::Array(mut items) if items.len() == 1 => match items.remove(0) {
331 Value::String(text) => text,
332 Value::Number(number) => number.to_string(),
333 _ => return Err(ProError::bad("Unsupported query value")),
334 },
335 _ => return Err(ProError::bad("Unsupported query value")),
336 };
337 if value.len() > 256 || value.chars().any(char::is_control) {
338 return Err(ProError::bad("Unsupported query value"));
339 }
340 out.insert(key, Value::String(value));
341 }
342 Ok(out)
343}
344
345fn only(map: &Map<String, Value>, keys: &[&str]) -> ProResult<()> {
346 match map.keys().find(|k| !keys.contains(&k.as_str())) {
347 Some(key) => Err(ProError::bad(format!("Unknown field {key}"))),
348 None => Ok(()),
349 }
350}
351
352fn text(map: &Map<String, Value>, key: &str, default: &str) -> String {
353 map.get(key)
354 .and_then(Value::as_str)
355 .filter(|v| !v.is_empty())
356 .unwrap_or(default)
357 .to_string()
358}
359
360fn flag(body: &Map<String, Value>, key: &str) -> ProResult<bool> {
361 match body.get(key) {
362 None | Some(Value::Null) => Ok(false),
363 Some(Value::Bool(value)) => Ok(*value),
364 Some(_) => Err(ProError::bad(format!("{key} must be true or false"))),
365 }
366}
367
368fn reason(body: &Map<String, Value>, default: &str) -> ProResult<String> {
369 match body.get("reason") {
370 None | Some(Value::Null) => Ok(default.to_string()),
371 Some(Value::String(text)) if plain_text(text, 120) => {
372 let text = text.trim();
373 Ok(if text.is_empty() { default } else { text }.to_string())
374 }
375 Some(_) => Err(ProError::bad("Give a reason of up to 120 characters")),
376 }
377}
378
379fn path(body: &Map<String, Value>) -> ProResult<String> {
380 body.get("path")
381 .and_then(Value::as_str)
382 .map(str::trim)
383 .filter(|p| !p.is_empty() && plain_text(p, 4096))
384 .map(str::to_string)
385 .ok_or_else(|| ProError::bad("path is required"))
386}
387
388pub(crate) fn is_profile_name(name: &str) -> bool {
389 (1..=60).contains(&name.len())
390 && name
391 .bytes()
392 .all(|b| b.is_ascii_alphanumeric() || b" ._-".contains(&b))
393}
394
395pub(crate) fn is_fact_ref(value: &str) -> bool {
397 let Some((category, key)) = value.split_once('/') else {
398 return false;
399 };
400 plain_text(value, 300)
401 && !category.is_empty()
402 && !key.is_empty()
403 && !category.starts_with('-')
404 && !key.starts_with('-')
405}
406
407fn is_month(value: &str) -> bool {
408 chrono::NaiveDate::parse_from_str(&format!("{value}-01"), "%Y-%m-%d").is_ok()
409 && value.len() == 7
410}
411
412pub(crate) fn is_day(value: &str) -> bool {
413 value.len() == 10 && chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()
414}
415
416fn ledger_query(op: Op, query: &Map<String, Value>, out: &mut Map<String, Value>) -> ProResult<()> {
417 let mut keys = vec!["tool", "model", "q", "from", "to", "project"];
418 if op == Op::LedgerBrowse {
419 keys.extend(["limit", "offset"]);
420 } else {
421 keys.push("format");
422 }
423 only(query, &keys)?;
424 let one = |name: &str, limit: usize| -> ProResult<String> {
425 let value = text(query, name, "");
426 if value.chars().count() > limit {
427 return Err(ProError::bad("Unsupported ledger filter"));
428 }
429 Ok(value)
430 };
431 let tool = one("tool", 60)?;
432 if !tool.is_empty()
433 && !tool
434 .bytes()
435 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.-".contains(&b))
436 {
437 return Err(ProError::bad("Unknown tool filter"));
438 }
439 let model = one("model", 80)?;
440 let q = one("q", 60)?.trim().to_string();
441 let from = one("from", 10)?;
442 let to = one("to", 10)?;
443 for day in [&from, &to] {
444 if !day.is_empty() && !is_day(day) {
445 return Err(ProError::bad("Use dates as YYYY-MM-DD"));
446 }
447 }
448 if !from.is_empty() && !to.is_empty() && from > to {
449 return Err(ProError::bad("The start date is after the end date"));
450 }
451 let project = one("project", 200)?;
452 for (key, value) in [
453 ("tool", tool),
454 ("model", model),
455 ("q", q),
456 ("from", from),
457 ("to", to),
458 ("project", project),
459 ] {
460 out.insert(key.into(), value.into());
461 }
462 if op == Op::LedgerBrowse {
463 let number = |name: &str, default: u64, max: u64| -> ProResult<u64> {
464 let raw = text(query, name, "");
465 if raw.is_empty() {
466 return Ok(default);
467 }
468 raw.parse::<u64>()
469 .ok()
470 .filter(|n| *n <= max)
471 .ok_or_else(|| ProError::bad("Use whole numbers for offset and limit"))
472 };
473 let limit = number("limit", 50, 500)?.max(1);
474 let offset = number("offset", 0, 100_000_000)?;
475 out.insert("limit".into(), limit.to_string().into());
476 out.insert("offset".into(), offset.to_string().into());
477 } else {
478 let format = text(query, "format", "csv");
479 if format != "csv" && format != "json" {
480 return Err(ProError::bad("Choose csv or json"));
481 }
482 out.insert("format".into(), format.into());
483 }
484 Ok(())
485}
486
487fn alerts_body(body: &Map<String, Value>, out: &mut Map<String, Value>) -> ProResult<()> {
488 only(body, &["enabled", "kinds", "desktop", "webhook"])?;
489 for key in ["enabled", "desktop"] {
490 if let Some(value) = body.get(key) {
491 if !value.is_boolean() {
492 return Err(ProError::bad(format!("{key} must be true or false")));
493 }
494 out.insert(key.into(), value.clone());
495 }
496 }
497 if let Some(kinds) = body.get("kinds") {
498 let list = kinds
499 .as_array()
500 .filter(|list| list.len() <= GUARD_KINDS.len())
501 .ok_or_else(|| ProError::bad("Choose alert kinds from the guard list"))?;
502 let mut checked: Vec<&str> = Vec::new();
503 for kind in list {
504 let kind = kind
505 .as_str()
506 .filter(|k| GUARD_KINDS.contains(k))
507 .ok_or_else(|| ProError::bad("Choose alert kinds from the guard list"))?;
508 if !checked.contains(&kind) {
509 checked.push(kind);
510 }
511 }
512 out.insert("kinds".into(), checked.into());
513 }
514 if let Some(webhook) = body.get("webhook") {
515 let webhook = webhook
516 .as_str()
517 .filter(|w| w.len() <= 500)
518 .ok_or_else(|| ProError::bad("Webhook must be a URL up to 500 characters"))?
519 .trim();
520 if !webhook.is_empty() && !is_safe_webhook(webhook) {
521 return Err(ProError::bad(
522 "Use an https webhook URL without embedded credentials",
523 ));
524 }
525 out.insert("webhook".into(), webhook.into());
526 }
527 Ok(())
528}
529
530pub(crate) fn is_safe_webhook(url: &str) -> bool {
533 let Some(rest) = url.strip_prefix("https://") else {
534 return false;
535 };
536 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
537 let host = authority.rsplit_once(':').map_or(authority, |(h, port)| {
538 if port.bytes().all(|b| b.is_ascii_digit()) && !port.is_empty() {
539 h
540 } else {
541 authority
542 }
543 });
544 url.len() <= 500
545 && !authority.contains('@')
546 && !host.is_empty()
547 && !url.chars().any(|c| c.is_control() || c.is_whitespace())
548 && host
549 .bytes()
550 .all(|b| b.is_ascii_alphanumeric() || b".-[]:".contains(&b))
551}