Skip to main content

speccoder_runtime/extensions/
views.rs

1//! Views: an extension's data shown by speccoder's own widgets. A `run`
2//! source runs like an observer command — in the sandbox, speccoder's
3//! state hidden, the checkout read-only — but with no network; a `file`
4//! source is read as it is. Results are kept with the digest of what they
5//! read and shown while that holds; nothing of the extension reaches the
6//! page but data.
7
8use std::collections::BTreeMap;
9use std::path::Path;
10use std::time::{Duration, Instant};
11
12use serde_json::Value;
13use speccoder_core::extensions::{
14    ViewData, ViewDef, ViewPlace, ViewResult, ViewShown, ViewSource, ViewWidget, ViewsReport,
15};
16use speccoder_core::hooks::TrustState;
17
18use super::{Loaded, trust};
19use crate::verify::process::Invocation;
20
21/// The most output a view's command may print.
22const MAX_OUTPUT: usize = 2 * 1024 * 1024;
23const MAX_ROWS: usize = 500;
24const MAX_METRICS: usize = 24;
25const MAX_METRIC_TEXT: usize = 80;
26const FILE: &str = "views.json";
27
28fn store(state: &Path, root: &Path) -> std::path::PathBuf {
29    crate::worktree::project_state_dir(state, root).join("extensions")
30}
31
32fn read_kept(dir: &Path) -> BTreeMap<String, ViewResult> {
33    std::fs::read_to_string(dir.join(FILE))
34        .ok()
35        .and_then(|t| serde_json::from_str(&t).ok())
36        .unwrap_or_default()
37}
38
39/// The views placed on `feature` (or, with `None`, on the project), each
40/// with its result for the current inputs: kept when they did not change,
41/// else produced now. `again` names a view to produce anew.
42#[must_use]
43pub fn views(
44    root: &Path,
45    state: &Path,
46    feature: Option<&str>,
47    again: Option<(&str, &str)>,
48) -> ViewsReport {
49    let dir = crate::hooks::dir(state, root);
50    let kept_dir = store(state, root);
51    let mut kept = read_kept(&kept_dir);
52    let feature_dir = feature.and_then(|f| super::feature_dir(root, f));
53    let place = if feature.is_some() {
54        ViewPlace::Feature
55    } else {
56        ViewPlace::Project
57    };
58    let mut out = Vec::new();
59    let mut changed = false;
60    for e in super::load(root).iter().filter(|e| e.usable()) {
61        let trusted = trust::state(&dir, e) == TrustState::Trusted;
62        for v in e.views.iter().filter(|v| v.place == place) {
63            if !trusted {
64                out.push(ViewShown {
65                    extension: e.name.clone(),
66                    view: v.clone(),
67                    result: None,
68                    waits_for_trust: true,
69                });
70                continue;
71            }
72            let key = format!("{}/{}/{}", e.name, v.name, feature.unwrap_or("-"));
73            let inputs = inputs(e, v, root, feature_dir.as_deref());
74            let fresh = again.is_none_or(|(x, y)| x != e.name || y != v.name);
75            let result = match kept.get(&key) {
76                Some(r) if r.inputs == inputs && fresh => r.clone(),
77                _ => {
78                    let r = produce(e, v, root, state, feature, feature_dir.as_deref(), inputs);
79                    kept.insert(key, r.clone());
80                    changed = true;
81                    r
82                }
83            };
84            out.push(ViewShown {
85                extension: e.name.clone(),
86                view: v.clone(),
87                result: Some(result),
88                waits_for_trust: false,
89            });
90        }
91    }
92    if changed {
93        crate::hooks::write_json(&kept_dir, FILE, &kept);
94    }
95    ViewsReport { views: out }
96}
97
98/// The digest of what `v` reads: the extension, its source, and the
99/// feature's files (a `run` source) or the file (a `file` source).
100fn inputs(e: &Loaded, v: &ViewDef, root: &Path, feature_dir: Option<&Path>) -> String {
101    use sha2::{Digest as _, Sha256};
102    let mut h = Sha256::new();
103    h.update(e.digest.as_bytes());
104    h.update(serde_json::to_vec(v).unwrap_or_default());
105    match &v.source {
106        ViewSource::File(f) => {
107            let base = match v.place {
108                ViewPlace::Feature => feature_dir.map(Path::to_path_buf),
109                ViewPlace::Project => Some(super::project(root)),
110            };
111            if let Some(b) = base {
112                h.update(std::fs::read(b.join(f)).unwrap_or_default());
113            }
114        }
115        ViewSource::Run(_) => {
116            if let Some(d) = feature_dir
117                && let Ok((digest, _)) = super::digest(d)
118            {
119                h.update(digest.as_bytes());
120            }
121        }
122    }
123    format!(
124        "sha256:{}",
125        h.finalize()
126            .iter()
127            .map(|b| format!("{b:02x}"))
128            .collect::<String>()
129    )
130}
131
132fn produce(
133    e: &Loaded,
134    v: &ViewDef,
135    root: &Path,
136    state: &Path,
137    feature: Option<&str>,
138    feature_dir: Option<&Path>,
139    inputs: String,
140) -> ViewResult {
141    let began = Instant::now();
142    let raw = match &v.source {
143        ViewSource::File(f) => {
144            let base = match v.place {
145                ViewPlace::Feature => feature_dir.map(Path::to_path_buf),
146                ViewPlace::Project => Some(super::project(root)),
147            };
148            match base {
149                None => Err("the feature's folder was not found".to_owned()),
150                Some(b) => std::fs::read_to_string(b.join(f)).map_err(|err| match err.kind() {
151                    std::io::ErrorKind::NotFound => format!("{f} does not exist yet"),
152                    _ => format!("{f}: {err}"),
153                }),
154            }
155        }
156        ViewSource::Run(argv) => run(e, v, argv, root, state, feature, feature_dir),
157    };
158    let duration_ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
159    let (data, error) = match raw.and_then(|text| shape(v, &text, root)) {
160        Ok(d) => (Some(d), None),
161        Err(err) => (None, Some(err)),
162    };
163    ViewResult {
164        inputs,
165        ran_at: crate::attest::now_rfc3339(),
166        duration_ms,
167        data,
168        error,
169    }
170}
171
172fn run(
173    e: &Loaded,
174    v: &ViewDef,
175    argv: &[String],
176    root: &Path,
177    state: &Path,
178    feature: Option<&str>,
179    feature_dir: Option<&Path>,
180) -> Result<String, String> {
181    let (runner, _) = crate::sandbox::runner_at(root)?;
182    if !runner.is_sandboxed() {
183        return Err(
184            "no sandbox is enforced here, and a view's command runs only in one".to_owned(),
185        );
186    }
187    let (runner, _) = runner.carve(
188        &[root.to_path_buf()],
189        &crate::hooks::gate::hidden_state(state, root),
190    );
191    if !runner.hides() {
192        return Err("no sandbox is enforced here that hides speccoder's state".to_owned());
193    }
194    let dir_text = feature_dir
195        .map(|d| d.to_string_lossy().into_owned())
196        .unwrap_or_default();
197    let argv = super::resolve(
198        argv,
199        Some(&e.folder),
200        &[("SPECCODER_FEATURE_DIR", dir_text.clone())],
201    );
202    let mut env = crate::verify::process::scrubbed_env(std::env::vars());
203    if let Some(f) = feature {
204        env.push(("SPECCODER_FEATURE".to_owned(), f.to_owned()));
205        env.push(("SPECCODER_FEATURE_DIR".to_owned(), dir_text));
206    }
207    env.push(("PYTHONDONTWRITEBYTECODE".to_owned(), "1".to_owned()));
208    let inv = Invocation {
209        argv,
210        cwd: e.folder.clone(),
211        env,
212        timeout: Duration::from_secs(v.timeout),
213        output_cap: MAX_OUTPUT + 1,
214    };
215    // No network at all, whatever the project's sandbox allows commands.
216    let ended = runner.run_offline(&inv);
217    if let Some(err) = ended.spawn_error {
218        return Err(format!("could not start: {err}"));
219    }
220    if ended.timed_out {
221        return Err(format!("it ran longer than {} s", v.timeout));
222    }
223    if ended.stdout.kept.len() > MAX_OUTPUT {
224        return Err("it printed more than 2 MB".to_owned());
225    }
226    let code = ended.exit_code.unwrap_or(-1);
227    if !v.exit.contains(&code) {
228        let stderr = String::from_utf8_lossy(&ended.stderr.kept);
229        let last = stderr
230            .lines()
231            .rev()
232            .find(|l| !l.trim().is_empty())
233            .unwrap_or("");
234        return Err(format!(
235            "it exited {code}{}",
236            if last.is_empty() {
237                String::new()
238            } else {
239                format!(": {}", speccoder_core::hooks::clean(last))
240            }
241        ));
242    }
243    Ok(String::from_utf8_lossy(&ended.stdout.kept).into_owned())
244}
245
246/// The output as the widget's data, or why it does not fit.
247fn shape(v: &ViewDef, text: &str, root: &Path) -> Result<ViewData, String> {
248    let project = super::project(root);
249    if v.widget == ViewWidget::Markdown {
250        return Ok(ViewData::Markdown {
251            html: crate::documents::render(text),
252        });
253    }
254    if v.widget == ViewWidget::Findings {
255        return speccoder_core::extensions::sarif(text, |p| super::relative_to(&project, p))
256            .map(|findings| ViewData::Findings { findings });
257    }
258    let json: Value =
259        serde_json::from_str(text).map_err(|err| format!("the output is not JSON: {err}"))?;
260    let picked = match &v.path {
261        None => &json,
262        Some(p) => p
263            .split('.')
264            .try_fold(&json, |at, k| at.get(k))
265            .ok_or_else(|| format!("the output has no `{p}`"))?,
266    };
267    let text_of = |v: &Value| match v {
268        Value::String(s) => s.clone(),
269        Value::Null => String::new(),
270        other => other.to_string(),
271    };
272    match v.widget {
273        ViewWidget::Table => {
274            let rows = picked
275                .as_array()
276                .ok_or("a table's data is a list of objects")?;
277            if rows.len() > MAX_ROWS {
278                return Err(format!(
279                    "{} rows; a table shows at most {MAX_ROWS}",
280                    rows.len()
281                ));
282            }
283            let columns: Vec<String> = if v.columns.is_empty() {
284                rows.first()
285                    .and_then(Value::as_object)
286                    .map(|o| o.keys().cloned().collect())
287                    .unwrap_or_default()
288            } else {
289                v.columns.clone()
290            };
291            let rows = rows
292                .iter()
293                .map(|r| {
294                    let o = r.as_object().ok_or("a table's data is a list of objects")?;
295                    Ok(columns
296                        .iter()
297                        .map(|c| o.get(c).map(text_of).unwrap_or_default())
298                        .collect())
299                })
300                .collect::<Result<Vec<Vec<String>>, &str>>()?;
301            Ok(ViewData::Table { columns, rows })
302        }
303        ViewWidget::Metrics => {
304            let o = picked
305                .as_object()
306                .ok_or("metrics are an object of names and values")?;
307            if o.len() > MAX_METRICS {
308                return Err(format!(
309                    "{} metrics; at most {MAX_METRICS} are shown",
310                    o.len()
311                ));
312            }
313            let metrics = o
314                .iter()
315                .map(|(k, v)| match v {
316                    Value::Number(_) | Value::Bool(_) => Ok((k.clone(), v.to_string())),
317                    Value::String(s) if s.chars().count() <= MAX_METRIC_TEXT => {
318                        Ok((k.clone(), s.clone()))
319                    }
320                    _ => Err(format!(
321                        "metric `{k}` is not a number or a word of at most {MAX_METRIC_TEXT} characters"
322                    )),
323                })
324                .collect::<Result<Vec<_>, _>>()?;
325            Ok(ViewData::Metrics { metrics })
326        }
327        ViewWidget::Findings | ViewWidget::Markdown => unreachable!("handled above"),
328    }
329}