Skip to main content

speccoder_core/
extensions.rs

1//! Extensions: a folder of someone else's work — gates, observers, views,
2//! skills, MCP servers — that a person trusts by one digest. The pure
3//! parts live here: names, the `[views.<name>]` grammar, what each
4//! contribution can reach in words, what widened since the last trust, and
5//! SARIF findings as speccoder shows them.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::hooks::{HookFile, TrustState};
12
13/// The most files one extension may hold.
14pub const MAX_FILES: usize = 2_000;
15/// The most bytes one extension may hold.
16pub const MAX_BYTES: u64 = 20 * 1024 * 1024;
17/// The most findings kept from one source.
18pub const MAX_FINDINGS: usize = 200;
19/// The largest SARIF file read.
20pub const MAX_SARIF: usize = 1024 * 1024;
21/// The longest finding message kept, in characters.
22const MAX_MESSAGE: usize = 500;
23
24/// Where an extension comes from.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum ExtensionSource {
28    /// An Agent Plugins 1.0 folder in `.speccoder/extensions/`.
29    AgentPlugins,
30    /// A Spec Kit extension in `.specify/extensions/`.
31    SpecKit,
32}
33
34/// Where `speccoder extensions add` copied an extension from.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36pub struct AddedFrom {
37    /// The repository, or the folder.
38    pub url: String,
39    /// The commit, for a repository.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub commit: Option<String>,
42    /// When (RFC 3339).
43    pub added: String,
44}
45
46/// What kind of thing an extension contributes.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(rename_all = "snake_case")]
49pub enum ContributionKind {
50    /// A `[hooks]` gate.
51    Gate,
52    /// A `[hooks]` observer.
53    Observer,
54    /// A `[views]` view.
55    View,
56    /// A `skills/<name>/SKILL.md`.
57    Skill,
58    /// An `mcp.json` server.
59    McpServer,
60    /// A Spec Kit command.
61    Command,
62    /// A Spec Kit hook offered at its step.
63    HookOffer,
64}
65
66/// One contribution and what it can reach, in words.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
68pub struct Contribution {
69    /// Its kind.
70    pub kind: ContributionKind,
71    /// Its qualified name: `threatspec.check`, `/threatspec:model`.
72    pub name: String,
73    /// What it does and reaches: `gate before tick: runs ./check.sh — no network`.
74    pub line: String,
75    /// What it reaches that a narrower one would not: `network`,
76    /// `host:hooks.example.com`, `event:task.stopped`.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub reaches: Vec<String>,
79}
80
81impl Contribution {
82    /// Its kind in a word or two: `gate`, `MCP server`.
83    #[must_use]
84    pub const fn kind_words(&self) -> &'static str {
85        match self.kind {
86            ContributionKind::Gate => "gate",
87            ContributionKind::Observer => "observer",
88            ContributionKind::View => "view",
89            ContributionKind::Skill => "skill",
90            ContributionKind::McpServer => "MCP server",
91            ContributionKind::Command => "command",
92            ContributionKind::HookOffer => "hook",
93        }
94    }
95}
96
97/// What changed since an extension was trusted.
98#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
99pub struct ExtensionChanges {
100    /// Files added, changed or gone.
101    pub files: Vec<String>,
102    /// Contributions that are new or reach further, first in any listing.
103    pub widened: Vec<String>,
104    /// Contributions that went.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub gone: Vec<String>,
107}
108
109/// One extension as `speccoder extensions` shows it.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111pub struct Extension {
112    /// Its name.
113    pub name: String,
114    /// Agent Plugins or Spec Kit.
115    pub source: ExtensionSource,
116    /// Its version, as it says.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub version: Option<String>,
119    /// What it says it is.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub description: Option<String>,
122    /// Its folder, relative to the project.
123    pub folder: String,
124    /// Where it was added from.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub origin: Option<AddedFrom>,
127    /// `sha256:<hex>` over every file.
128    pub digest: String,
129    /// Every file and its digest.
130    pub files: Vec<HookFile>,
131    /// What it contributes.
132    pub contributions: Vec<Contribution>,
133    /// Whether it may run here.
134    pub trust: TrustState,
135    /// For a changed extension: what changed.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub changes: Option<ExtensionChanges>,
138    /// The flag that trusts it in CI: `--trust-extension name=sha256:…`.
139    pub trust_flag: String,
140    /// Why it cannot be loaded; nothing of it is offered while any.
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub problems: Vec<String>,
143}
144
145/// `speccoder extensions`: every extension, and what was just done.
146#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
147pub struct ExtensionsReport {
148    /// Every extension, by name.
149    pub extensions: Vec<Extension>,
150    /// Extensions just trusted.
151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
152    pub trusted: Vec<String>,
153    /// The extension just added.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub added: Option<String>,
156    /// The extension just removed.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub removed: Option<String>,
159}
160
161/// Whether `name` may name an extension (Agent Plugins 1.0): 1–64 of
162/// `a-z0-9-.`, a letter or digit at both ends, no `--` or `..`.
163#[must_use]
164pub fn valid_name(name: &str) -> bool {
165    let b = name.as_bytes();
166    (1..=64).contains(&b.len())
167        && b.iter()
168            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'-' | b'.'))
169        && b[0].is_ascii_alphanumeric()
170        && b[b.len() - 1].is_ascii_alphanumeric()
171        && !name.contains("--")
172        && !name.contains("..")
173}
174
175/// The MCP server name of an extension's server: `<ext>_<server>`, every
176/// run of `-`, `.` and `_` as one `_` (a tool reference's component:
177/// lowercase letters, digits and single `_` inside).
178#[must_use]
179pub fn server_name(extension: &str, server: &str) -> String {
180    let mut out = String::new();
181    for c in format!("{extension}_{server}").to_lowercase().chars() {
182        let c = if c.is_ascii_alphanumeric() { c } else { '_' };
183        if !(c == '_' && (out.is_empty() || out.ends_with('_'))) {
184            out.push(c);
185        }
186    }
187    out.trim_end_matches('_').to_owned()
188}
189
190// ── views ──────────────────────────────────────────────────────────────
191
192/// How a view shows its data.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
194#[serde(rename_all = "snake_case")]
195pub enum ViewWidget {
196    /// SARIF findings.
197    Findings,
198    /// Rows with columns.
199    Table,
200    /// Named numbers or short words.
201    Metrics,
202    /// A document.
203    Markdown,
204}
205
206/// Where a view stands.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
208#[serde(rename_all = "snake_case")]
209pub enum ViewPlace {
210    /// On each feature's page, about that feature.
211    #[default]
212    Feature,
213    /// On the project's overview.
214    Project,
215}
216
217/// Where a view's data comes from.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
219#[serde(rename_all = "snake_case")]
220pub enum ViewSource {
221    /// A command, run in the sandbox.
222    Run(Vec<String>),
223    /// A file, read as it is.
224    File(String),
225}
226
227/// One `[views.<name>]`.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
229pub struct ViewDef {
230    /// The table's name.
231    pub name: String,
232    /// Its heading.
233    pub title: String,
234    /// Its data.
235    pub source: ViewSource,
236    /// How it shows.
237    pub widget: ViewWidget,
238    /// A dotted path into JSON output.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub path: Option<String>,
241    /// A table's columns.
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub columns: Vec<String>,
244    /// Where it stands.
245    pub place: ViewPlace,
246    /// Seconds a command may run.
247    pub timeout: u64,
248    /// Exit codes whose output is still data.
249    pub exit: Vec<i32>,
250}
251
252const VIEW_KEYS: &[&str] = &[
253    "title", "run", "file", "widget", "path", "columns", "place", "timeout", "exit",
254];
255
256/// Parses `[views.<name>]` (as JSON).
257///
258/// # Errors
259///
260/// What is wrong, with the fix.
261pub fn parse_view(name: &str, table: &Value) -> Result<ViewDef, String> {
262    let at = |m: &str| format!("[views.{name}]: {m}");
263    if !crate::hooks::valid_name(name) {
264        return Err(at(
265            "a view's name is lowercase letters, digits, `-` and `_`",
266        ));
267    }
268    let Some(t) = table.as_object() else {
269        return Err(at("must be a table"));
270    };
271    if let Some(k) = t.keys().find(|k| !VIEW_KEYS.contains(&k.as_str())) {
272        return Err(at(&format!(
273            "unknown key `{k}` (known: {})",
274            VIEW_KEYS.join(", ")
275        )));
276    }
277    let string = |k: &str| -> Result<Option<String>, String> {
278        match t.get(k) {
279            None => Ok(None),
280            Some(Value::String(s)) => Ok(Some(s.clone())),
281            Some(_) => Err(at(&format!("`{k}` is a string"))),
282        }
283    };
284    let strings = |k: &str| -> Result<Option<Vec<String>>, String> {
285        match t.get(k) {
286            None => Ok(None),
287            Some(Value::Array(a)) => a
288                .iter()
289                .map(|v| v.as_str().map(str::to_owned))
290                .collect::<Option<Vec<_>>>()
291                .map(Some)
292                .ok_or_else(|| at(&format!("`{k}` is a list of strings"))),
293            Some(_) => Err(at(&format!("`{k}` is a list of strings"))),
294        }
295    };
296    let title = string("title")?.unwrap_or_else(|| name.to_owned());
297    if title.chars().count() > 60 {
298        return Err(at("`title` is at most 60 characters"));
299    }
300    let source = match (strings("run")?, string("file")?) {
301        (Some(_), Some(_)) => return Err(at("give `run` or `file`, not both")),
302        (None, None) => return Err(at("add `run = [\"…\"]` or `file = \"…\"`")),
303        (Some(a), None) if a.first().is_none_or(|p| p.trim().is_empty()) => {
304            return Err(at("`run` names the program first"));
305        }
306        (Some(a), None) => ViewSource::Run(a),
307        (None, Some(f)) => {
308            if f.is_empty() || f.starts_with('/') || f.split(['/', '\\']).any(|s| s == "..") {
309                return Err(at(&format!(
310                    "`file`: `{f}` is not a path inside the project"
311                )));
312            }
313            ViewSource::File(f)
314        }
315    };
316    let widget = match string("widget")?.as_deref() {
317        Some("findings") => ViewWidget::Findings,
318        Some("table") => ViewWidget::Table,
319        Some("metrics") => ViewWidget::Metrics,
320        Some("markdown") => ViewWidget::Markdown,
321        Some(w) => {
322            return Err(at(&format!(
323                "`widget = \"{w}\"` is not supported by this version (findings, table, metrics, markdown)"
324            )));
325        }
326        None => return Err(at("add `widget` (findings, table, metrics, markdown)")),
327    };
328    let place = match string("place")?.as_deref() {
329        None | Some("feature") => ViewPlace::Feature,
330        Some("project") => ViewPlace::Project,
331        Some(p) => return Err(at(&format!("`place = \"{p}\"`: feature or project"))),
332    };
333    let timeout = match t.get("timeout") {
334        None => 60,
335        Some(v) => match v.as_u64() {
336            Some(s) if (1..=crate::hooks::MAX_TIMEOUT).contains(&s) => s,
337            _ => {
338                return Err(at(&format!(
339                    "`timeout` is seconds, 1 to {}",
340                    crate::hooks::MAX_TIMEOUT
341                )));
342            }
343        },
344    };
345    let exit = match t.get("exit") {
346        None => vec![0],
347        Some(Value::Array(a)) if !a.is_empty() => a
348            .iter()
349            .map(|v| v.as_i64().and_then(|n| i32::try_from(n).ok()))
350            .collect::<Option<Vec<_>>>()
351            .ok_or_else(|| at("`exit` is a list of exit codes"))?,
352        Some(_) => return Err(at("`exit` is a list of exit codes")),
353    };
354    if !matches!(source, ViewSource::Run(_)) && t.contains_key("exit") {
355        return Err(at("`exit` is for a `run` source"));
356    }
357    Ok(ViewDef {
358        name: name.to_owned(),
359        title,
360        source,
361        widget,
362        path: string("path")?,
363        columns: strings("columns")?.unwrap_or_default(),
364        place,
365        timeout,
366        exit,
367    })
368}
369
370/// A view's data, ready for its widget.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
372#[serde(rename_all = "snake_case", tag = "widget")]
373pub enum ViewData {
374    /// Findings.
375    Findings {
376        /// Each finding.
377        findings: Vec<SarifFinding>,
378    },
379    /// Rows.
380    Table {
381        /// The columns.
382        columns: Vec<String>,
383        /// Each row's cells, as text.
384        rows: Vec<Vec<String>>,
385    },
386    /// Named values.
387    Metrics {
388        /// `(name, value)`, values as text.
389        metrics: Vec<(String, String)>,
390    },
391    /// A document, rendered (raw HTML shown as text).
392    Markdown {
393        /// The HTML.
394        html: String,
395    },
396}
397
398/// A view's last result.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
400pub struct ViewResult {
401    /// The digest of what it read.
402    pub inputs: String,
403    /// When it ran (RFC 3339).
404    pub ran_at: String,
405    /// How long.
406    pub duration_ms: u64,
407    /// Its data.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub data: Option<ViewData>,
410    /// Why there is no data, in words.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub error: Option<String>,
413}
414
415/// One view as the app shows it.
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
417pub struct ViewShown {
418    /// The extension.
419    pub extension: String,
420    /// The view.
421    pub view: ViewDef,
422    /// Its result for the current inputs; none while it waits for trust.
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub result: Option<ViewResult>,
425    /// The extension waits for trust (or changed since).
426    pub waits_for_trust: bool,
427}
428
429/// The views placed on a feature (or the project).
430#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
431pub struct ViewsReport {
432    /// Every view, by extension then name.
433    pub views: Vec<ViewShown>,
434}
435
436// ── what each contribution can reach ───────────────────────────────────
437
438/// The host of an http(s) URL.
439#[must_use]
440pub fn host(url: &str) -> String {
441    let rest = url.split_once("://").map_or(url, |(_, r)| r);
442    let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
443    host.rsplit_once('@').map_or(host, |(_, h)| h).to_owned()
444}
445
446/// A command as a person reads it: its words, quoted where needed.
447#[must_use]
448pub fn shown(argv: &[String]) -> String {
449    argv.iter()
450        .map(|a| {
451            if a.is_empty() || a.contains([' ', '"', '\'']) {
452                format!("{a:?}")
453            } else {
454                a.clone()
455            }
456        })
457        .collect::<Vec<_>>()
458        .join(" ")
459}
460
461/// A hook's contribution, `name` qualified (`threatspec.check`).
462#[must_use]
463pub fn hook_contribution(name: &str, def: &crate::hooks::HookDef) -> Contribution {
464    use crate::hooks::{Role, Runner};
465    match (&def.role, &def.runner) {
466        (Role::Gate { at, network }, runner) => {
467            let runs = match runner {
468                Runner::Argv(a) => shown(a),
469                Runner::Url { url, .. } => url.clone(),
470            };
471            let net = if *network { "network on" } else { "no network" };
472            Contribution {
473                kind: ContributionKind::Gate,
474                name: name.to_owned(),
475                line: format!("gate {}: runs {runs} — {net}", at.words()),
476                reaches: if *network {
477                    vec!["network".to_owned()]
478                } else {
479                    Vec::new()
480                },
481            }
482        }
483        (Role::Observer { on }, runner) => {
484            let mut reaches: Vec<String> = on.iter().map(|e| format!("event:{e}")).collect();
485            let does = match runner {
486                Runner::Argv(a) => {
487                    reaches.push("network".to_owned());
488                    format!("runs {} (network on)", shown(a))
489                }
490                Runner::Url { url, .. } => {
491                    let h = host(url);
492                    reaches.push(format!("host:{h}"));
493                    format!("posts to {h}")
494                }
495            };
496            Contribution {
497                kind: ContributionKind::Observer,
498                name: name.to_owned(),
499                line: format!("observer, {}: {does}", def.listens()),
500                reaches,
501            }
502        }
503    }
504}
505
506/// A view's contribution, `name` qualified.
507#[must_use]
508pub fn view_contribution(name: &str, v: &ViewDef) -> Contribution {
509    let place = match v.place {
510        ViewPlace::Feature => "on each feature",
511        ViewPlace::Project => "on the project",
512    };
513    let from = match &v.source {
514        ViewSource::Run(a) => format!("runs {} read-only, no network", shown(a)),
515        ViewSource::File(f) => format!("reads {f}"),
516    };
517    Contribution {
518        kind: ContributionKind::View,
519        name: name.to_owned(),
520        line: format!("view “{}” {place}: {from}", v.title),
521        reaches: Vec::new(),
522    }
523}
524
525/// What changed between the contributions trusted (`before`) and now.
526#[must_use]
527pub fn widened(before: &[Contribution], now: &[Contribution]) -> (Vec<String>, Vec<String>) {
528    let mut widened = Vec::new();
529    for c in now {
530        match before.iter().find(|b| b.name == c.name && b.kind == c.kind) {
531            None => widened.push(format!("new — {}", c.line)),
532            Some(b) => {
533                let more: Vec<&String> = c
534                    .reaches
535                    .iter()
536                    .filter(|r| !b.reaches.contains(r))
537                    .collect();
538                if !more.is_empty() {
539                    let more: Vec<&str> = more.iter().map(|s| s.as_str()).collect();
540                    widened.push(format!("reaches {} — {}", more.join(", "), c.line));
541                }
542            }
543        }
544    }
545    let gone = before
546        .iter()
547        .filter(|b| !now.iter().any(|c| c.name == b.name && c.kind == b.kind))
548        .map(|b| b.line.clone())
549        .collect();
550    (widened, gone)
551}
552
553// ── findings ───────────────────────────────────────────────────────────
554
555/// One finding, as speccoder shows it.
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
557pub struct SarifFinding {
558    /// The rule's id.
559    pub rule: String,
560    /// `error`, `warning`, `note` or `none`.
561    pub level: String,
562    /// What it says.
563    pub message: String,
564    /// The file, relative to the project.
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub file: Option<String>,
567    /// The line.
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub line: Option<u64>,
570}
571
572/// The findings of a SARIF 2.1.0 log: at most [`MAX_FINDINGS`], each
573/// message cut to 500 characters, each location made relative to the
574/// project by `relative` (`None`: outside it).
575///
576/// # Errors
577///
578/// Why the log cannot be shown: too large, not JSON, not SARIF, or a
579/// location outside the project.
580pub fn sarif(
581    text: &str,
582    relative: impl Fn(&str) -> Option<String>,
583) -> Result<Vec<SarifFinding>, String> {
584    if text.len() > MAX_SARIF {
585        return Err(format!(
586            "the findings are {} KB; at most {} KB are read",
587            text.len() / 1024,
588            MAX_SARIF / 1024
589        ));
590    }
591    let v: Value = serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
592    let Some(runs) = v.get("runs").and_then(Value::as_array) else {
593        return Err("not SARIF: no `runs`".to_owned());
594    };
595    let mut out = Vec::new();
596    for r in runs.iter().flat_map(|r| {
597        r.get("results")
598            .and_then(Value::as_array)
599            .cloned()
600            .unwrap_or_default()
601    }) {
602        if out.len() == MAX_FINDINGS {
603            break;
604        }
605        let loc = &r["locations"][0]["physicalLocation"];
606        let file = match loc["artifactLocation"]["uri"].as_str() {
607            None => None,
608            Some(u) => {
609                let u = u.trim_start_matches("file://").trim_start_matches("./");
610                Some(
611                    relative(u)
612                        .ok_or_else(|| format!("a finding names `{u}`, outside the project"))?,
613                )
614            }
615        };
616        let message: String = r["message"]["text"]
617            .as_str()
618            .unwrap_or_default()
619            .chars()
620            .take(MAX_MESSAGE)
621            .collect();
622        out.push(SarifFinding {
623            rule: r["ruleId"].as_str().unwrap_or("finding").to_owned(),
624            level: match r["level"].as_str() {
625                Some(l @ ("error" | "warning" | "note" | "none")) => l.to_owned(),
626                _ => "warning".to_owned(),
627            },
628            message,
629            file,
630            line: loc["region"]["startLine"].as_u64(),
631        });
632    }
633    Ok(out)
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use serde_json::json;
640
641    #[test]
642    fn names_follow_agent_plugins() {
643        for ok in ["threatspec", "a", "my.tool-2"] {
644            assert!(valid_name(ok), "{ok}");
645        }
646        for bad in ["", "-a", "a-", "A", "a--b", "a..b", "a_b", &"x".repeat(65)] {
647            assert!(!valid_name(bad), "{bad}");
648        }
649        assert_eq!(server_name("my.tool", "echo_1"), "my_tool_echo_1");
650        assert_eq!(server_name("plain-plugin", "notes"), "plain_plugin_notes");
651    }
652
653    #[test]
654    fn a_view_has_one_source_and_a_widget() {
655        let v = parse_view(
656            "findings",
657            &json!({"run": ["./check.sh", "--sarif"], "widget": "findings", "exit": [0, 1, 2]}),
658        )
659        .unwrap();
660        assert_eq!(v.exit, vec![0, 1, 2]);
661        assert_eq!(v.timeout, 60);
662        assert_eq!(v.place, ViewPlace::Feature);
663        assert!(parse_view("x", &json!({"widget": "table"})).is_err());
664        assert!(parse_view("x", &json!({"run": ["a"], "file": "b", "widget": "table"})).is_err());
665        let e = parse_view("x", &json!({"file": "a.md", "widget": "chart"})).unwrap_err();
666        assert!(e.contains("not supported by this version"), "{e}");
667        assert!(parse_view("x", &json!({"file": "../a.md", "widget": "markdown"})).is_err());
668        assert!(
669            parse_view(
670                "x",
671                &json!({"file": "a.md", "widget": "markdown", "colour": 1})
672            )
673            .is_err()
674        );
675        assert!(
676            parse_view(
677                "x",
678                &json!({"file": "a.md", "widget": "markdown", "exit": [1]})
679            )
680            .is_err()
681        );
682    }
683
684    #[test]
685    fn a_new_host_or_the_network_widens() {
686        let gate = |net: bool| {
687            let def = crate::hooks::parse(
688                "check",
689                &json!({"at": "tick", "argv": ["./check.sh"], "network": net}),
690            )
691            .unwrap();
692            hook_contribution("x.check", &def)
693        };
694        let off = gate(false);
695        assert!(
696            off.line
697                .contains("gate before tick: runs ./check.sh — no network"),
698            "{}",
699            off.line
700        );
701        let (w, gone) = widened(std::slice::from_ref(&off), &[gate(true)]);
702        assert_eq!(w.len(), 1);
703        assert!(w[0].starts_with("reaches network"), "{w:?}");
704        assert!(gone.is_empty());
705        let obs = crate::hooks::parse(
706            "notify",
707            &json!({"on": ["task.stopped"], "url": "https://hooks.example.com/x"}),
708        )
709        .unwrap();
710        let (w, _) = widened(&[off], &[hook_contribution("x.notify", &obs)]);
711        assert!(w[0].starts_with("new — observer"), "{w:?}");
712        assert!(w[0].contains("posts to hooks.example.com"), "{w:?}");
713    }
714
715    #[test]
716    fn sarif_is_read_within_bounds() {
717        let log = json!({"version": "2.1.0", "runs": [{"results": [
718            {"ruleId": "TS-1", "level": "error", "message": {"text": "unmitigated"},
719             "locations": [{"physicalLocation": {"artifactLocation": {"uri": "specs/a/spec.md"},
720                                                 "region": {"startLine": 3}}}]}]}]});
721        let rel = |p: &str| (!p.starts_with('/')).then(|| p.to_owned());
722        let f = sarif(&log.to_string(), rel).unwrap();
723        assert_eq!(f[0].rule, "TS-1");
724        assert_eq!(f[0].line, Some(3));
725        let outside = log.to_string().replace("specs/a/spec.md", "/etc/passwd");
726        assert!(sarif(&outside, rel).is_err());
727        assert!(sarif("{}", rel).is_err());
728        assert!(sarif(&"x".repeat(MAX_SARIF + 1), rel).is_err());
729    }
730}