Skip to main content

lean_ctx/core/dashboard_pro/
request.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The cockpit's request `{op, method, query, body}`, checked per op before
3//! anything is read: allowed keys, bounds, patterns and values.
4
5use serde_json::{Map, Value};
6
7use super::{GUARD_KINDS, ProError, ProResult, plain_text};
8
9/// Upper bound of a cockpit request body.
10pub(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    /// The cockpit's alerts loop sends this op; the engine supplies the events.
26    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    /// Ops that read only device-wide stores (audit trail, receipts, the
64    /// savings ledger, host sessions) and so answer "All projects" too. The
65    /// others read or write one project's knowledge and need a project.
66    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    /// The effects a runtime answer to this op may plan.
104    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/// A checked request: only known keys, values normalized.
122#[derive(Debug, Clone)]
123pub(crate) struct BridgeRequest {
124    pub(crate) op: Op,
125    /// Query values as strings.
126    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
142/// Parses and checks a cockpit request.
143pub(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
155/// Checks a request object `{op, method, query, body}`.
156pub(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
182/// Checks an op's query and body (also used for the watch's own requests).
183pub(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
395/// `category/key` of a fact, neither part empty or starting with `-`.
396pub(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
530/// An `https://host/...` URL of at most 500 characters, without user info,
531/// whitespace or control characters.
532pub(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}