speccoder_runtime/extensions/
views.rs1use 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
21const 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#[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
98fn 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 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
246fn 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}