Skip to main content

lattice_plugin_host/
manifest.rs

1//! The plugin manifest — a plugin's *declared* capability request.
2//!
3//! Design fragment: `docs/dev/architecture/plugin-host.md` §6. Slice: PH7.2.
4//!
5//! A manifest declares **what a plugin asks for**, not what it gets. The
6//! *grant* — what a plugin is actually granted — is computed from the manifest
7//! plus its [`crate::TrustTier`] (and, for user-installed plugins, consent);
8//! see [`crate::capability`]. The manifest is untrusted input: a plugin cannot
9//! declare its own trust tier (that would defeat the point), so the tier is a
10//! host-supplied argument to grant computation, never a manifest field.
11//!
12//! The manifest is a committed **TOML** format so the Phase-8 plugin manager
13//! can discover + parse it off disk; the host itself consumes an
14//! already-parsed [`PluginManifest`] (there is no on-disk plugin discovery at
15//! PH7.2 — that is the plugin manager, deferred to Phase 8).
16//!
17//! ```toml
18//! id = "fuzzy-finder"
19//! capabilities = ["fs:read:/home/alice/project", "net:http:crates.io"]
20//! editor_capabilities = ["tree-sitter"]
21//! ```
22//!
23//! Two capability namespaces meet here and stay distinct:
24//! - **OS capabilities** ([`Capability`]) gate the plugin's WASI view
25//!   (`fs:*` / `net:*` / `proc:*`). These are enforced by the runtime.
26//! - **Editor capabilities** ([`CapabilitySet`]) are the *same* set
27//!   [`lattice_mode::Mode::required_capabilities`] returns; a plugin that
28//!   declares a mode carries its capability requirements here. Enforcement
29//!   stays the mode-activation path (PH7.11) — this slice only sizes the
30//!   manifest honestly per fragment §6.
31
32use lattice_core::home::expand_tilde;
33use std::path::PathBuf;
34use std::str::FromStr;
35
36use lattice_mode::CapabilitySet;
37use serde::{Deserialize, Serialize};
38
39/// An OS-level capability a plugin requests in its manifest. Distinct from
40/// [`CapabilitySet`] (editor/buffer capabilities); these gate the plugin's
41/// WASI view (filesystem / network / process).
42///
43/// Wire form (manifest + [`Display`]): `fs:read:<prefix>`, `fs:write:<prefix>`,
44/// `net:http:<host>`, `proc:spawn`, `state:write`. The `<prefix>` may itself
45/// contain `:` (paths, `host:port`); only the first two segments are the
46/// discriminator.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Capability {
49    /// Read access to a host path prefix (`fs:read:<prefix>`).
50    FsRead(PathBuf),
51    /// Read + write access to a host path prefix (`fs:write:<prefix>`).
52    FsWrite(PathBuf),
53    /// Outbound HTTP to one host-allowlist entry (`net:http:<host>`).
54    NetHttp(String),
55    /// Permission to spawn subprocesses (`proc:spawn`). Bundled-only in v1
56    /// (dropped from a user-installed plugin's grant — fragment §6).
57    ProcSpawn,
58    /// OR.1: permission to persist bytes in the plugin's own key/value store
59    /// (`state:write`, the `host-services` `store-*` calls).
60    ///
61    /// Its own capability rather than a corollary of `fs:write`, because the
62    /// two answer different questions. `fs:write:<prefix>` is *reach* — which
63    /// of the user's files a plugin may alter — and a plugin that persists an
64    /// index needs none of that. Folding the store into `fs:write` would make
65    /// "remember something between restarts" require a grant over the user's
66    /// documents, which is the wrong trade in the direction that matters.
67    StateWrite,
68    /// CM.2: permission to bind an operator's chord into the universal
69    /// operator-pending grammar (`gc{motion}`, `gcc`, Visual `gc`).
70    ///
71    /// A capability rather than a free contribution because it is the most
72    /// user-visible power a plugin can take: it claims keys in the grammar
73    /// every buffer shares, and a chord the user did not expect is worse than
74    /// a feature they did not get. Declaring it puts the claim in
75    /// `plugin.toml`, in the grant `:plugins` displays, and in the denial list
76    /// when a tier withholds it.
77    ///
78    /// Granted at BOTH tiers, unlike `proc:spawn`. The chord lands in the
79    /// plugin's own `MinorMode` layer rather than `Builtin`, it is visible in
80    /// `:plugins`, and unload reverses it by provenance — so the blast radius
81    /// is the plugin's own modes. Withholding it from user-installed plugins
82    /// would make contributing an operator a bundled-only feature, which is
83    /// precisely the "adding new operators is first-class" claim (paramount
84    /// #3) that the plugin API exists to honour.
85    ///
86    /// Withheld is NOT a load failure: the operator still registers and stays
87    /// reachable by name. A plugin never silently mis-binds
88    /// (`register-binding`'s contract), and a refused chord is a decision
89    /// rather than a broken wire.
90    GrammarChord,
91}
92
93/// The string `s` was not a recognised capability form.
94#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
95#[error(
96    "unrecognised capability `{0}` (expected `fs:read:<p>` / `fs:write:<p>` / `net:http:<host>` / `proc:spawn` / `state:write` / `grammar:chord`)"
97)]
98pub struct CapabilityParseError(pub String);
99
100impl FromStr for Capability {
101    type Err = CapabilityParseError;
102
103    fn from_str(s: &str) -> Result<Self, Self::Err> {
104        // `splitn(3, ':')` keeps everything after the second colon intact, so
105        // a path or `host:port` prefix survives whole.
106        let mut parts = s.splitn(3, ':');
107        match (parts.next(), parts.next(), parts.next()) {
108            // `~` expands HERE, at the parse, so every later comparison is
109            // against a real path. A grant is matched by prefix against a
110            // canonicalized target, and `~/org` canonicalizes to nothing — so
111            // an unexpanded tilde does not narrow the grant, it VOIDS it, and
112            // every write is refused with a message that says the path is
113            // outside the granted paths. Which is true, and useless.
114            (Some("fs"), Some("read"), Some(p)) if !p.is_empty() => {
115                Ok(Capability::FsRead(PathBuf::from(expand_tilde(p))))
116            }
117            (Some("fs"), Some("write"), Some(p)) if !p.is_empty() => {
118                Ok(Capability::FsWrite(PathBuf::from(expand_tilde(p))))
119            }
120            (Some("net"), Some("http"), Some(h)) if !h.is_empty() => {
121                Ok(Capability::NetHttp(h.to_string()))
122            }
123            (Some("proc"), Some("spawn"), None) => Ok(Capability::ProcSpawn),
124            (Some("state"), Some("write"), None) => Ok(Capability::StateWrite),
125            (Some("grammar"), Some("chord"), None) => Ok(Capability::GrammarChord),
126            _ => Err(CapabilityParseError(s.to_string())),
127        }
128    }
129}
130
131impl std::fmt::Display for Capability {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        match self {
134            Capability::FsRead(p) => write!(f, "fs:read:{}", p.display()),
135            Capability::FsWrite(p) => write!(f, "fs:write:{}", p.display()),
136            Capability::NetHttp(h) => write!(f, "net:http:{h}"),
137            Capability::ProcSpawn => f.write_str("proc:spawn"),
138            Capability::StateWrite => f.write_str("state:write"),
139            Capability::GrammarChord => f.write_str("grammar:chord"),
140        }
141    }
142}
143
144/// Map an editor-capability name (dashed, lowercase) to its [`CapabilitySet`]
145/// bit. Names mirror the `capability.rs` documentation in `lattice-mode`.
146fn parse_editor_capability(name: &str) -> Option<CapabilitySet> {
147    Some(match name {
148        "buffer-uri" => CapabilitySet::BUFFER_URI,
149        "lsp" => CapabilitySet::LSP,
150        "tree-sitter" => CapabilitySet::TREE_SITTER,
151        "folds" => CapabilitySet::FOLDS,
152        "writable" => CapabilitySet::WRITABLE,
153        "diagnostics" => CapabilitySet::DIAGNOSTICS,
154        _ => return None,
155    })
156}
157
158/// Which extension seam a plugin's component implements — the WIT world it
159/// exports. Each seam is its own world (`picker-source`, `events-plugin`, …); a
160/// component implements one, and the manifest declares which so the plugin
161/// loader (`lattice-plugin-loader`) knows which `spawn_*` path to drive. An
162/// empty `provides` list is a **lifecycle-only** plugin (the base `plugin`
163/// world — `init.rs`, the no-op fixture), driven through `instantiate_plugin` +
164/// `activate` rather than a seam actor.
165///
166/// Wire form (manifest `provides = [...]` + [`Display`]) is the dashed WIT
167/// interface name: `picker-source`, `completion-source`, `grammar`, `events`,
168/// `modes`, `config`, `decorations`, `context`.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum PluginSeam {
171    PickerSource,
172    /// MV.1 — a plugin-contributed multibuffer VIEW: the guest owns the view's
173    /// identity, contents, order and status, and the host owns the buffer and
174    /// the excerpt machinery. Distinct from `ScannedExcerptSource`, which
175    /// supplies *rows* to a view someone else owns.
176    MultibufferViewSource,
177    CompletionSource,
178    Grammar,
179    Events,
180    Modes,
181    Config,
182    Decorations,
183    /// IM.6: inline media blocks (`inline-media.md` §7). A producer, like
184    /// `Decorations` — the guest names files and lines, the host resolves
185    /// sizes and builds the virtual rows.
186    Media,
187    /// TC.2 — the sticky-context producer (`context-plugin` world). Async,
188    /// host-cached per parse version; see `treesitter-context.md`.
189    Context,
190    /// TC.4 — theme-element declaration (`theme-plugin` world). The plugin's
191    /// elements land in the SAME registry builtins use.
192    Theme,
193    /// SG.3a — sign declaration (`sign-plugin` world). The plugin's signs land
194    /// in the SAME registry native producers use, styled through the ordinary
195    /// theme registry.
196    Signs,
197    Keymap,
198    /// The `wasi:logging`-shaped guest→host logging import (PO.5, Layer 2). Not a
199    /// native trait seam — the guest's own narrative, host-captured into the same
200    /// tracer as the boundary trace.
201    Logging,
202    /// PM.7 — the `require` seam (`plugin-manager-plugin` world). A config
203    /// guest (the user's `init.rs`) declares the plugins it wants; the host
204    /// records the specs and the loader resolves/builds/loads them off-thread.
205    /// Deliberately its own seam rather than folded into `config`: declaring
206    /// software to install is a strictly larger authority than setting an
207    /// option, and `provides` is where that difference is visible to a user
208    /// reading the manifest.
209    PluginManager,
210    /// CM.6 — a compilation-output parser (`error-parser-plugin` world). Sync,
211    /// fed one captured line at a time by the compilation reader.
212    ErrorParser,
213    /// CR.3 — plugin-contributed `:help` pages (`help-plugin` world). The
214    /// markdown ships inside the component and crosses once, at load; the
215    /// topic then lives in the same registry the builtin docs do.
216    Help,
217    /// CR.4 — plugin-contributed launch-page sections (`dashboard-plugin`
218    /// world). Sync, and unlike `help` the guest stays instantiated: a
219    /// section is a function of a live `DashboardCtx`, not data.
220    Dashboard,
221    /// OM.A1 — a plugin-contributed producer of **scanned excerpts**: rows
222    /// found by reading many files, globally ordered and grouped
223    /// (`scanned-excerpt-source-plugin` world). Async, driven once per file of
224    /// a project walk. The guest declares the file extensions it wants
225    /// offered, which is what keeps a filetype out of the host's walk.
226    ///
227    /// OR.9 renamed this from `AgendaSource`. Its record — line, end-line,
228    /// group, label, sort-key — is an excerpt plus an ordering plus a group
229    /// header, and contains nothing about org, dates or TODOs. The agenda is
230    /// its first consumer, not its definition; a project-TODO view or a tags
231    /// view is the same shape and should not have to register as an "agenda
232    /// source". Renamed while org was still the only consumer, because a seam
233    /// name is API and third-party plugins would have bound to it.
234    ScannedExcerptSource,
235    /// TR.2b — a plugin-contributed transient menu (`transient-source-plugin`
236    /// world). Async and live: `id()` is called once at load to key the
237    /// registry entry, `build(ctx)` per menu open, because a builder's rows
238    /// depend on where the user opened it from.
239    TransientSource,
240    /// LG.3c — a plugin-contributed language (`language-plugin` world). Data,
241    /// like `help`: the grammar bytes and query sources cross once at load and
242    /// the guest is dropped. The host compiles the grammar (~100 ms) and owns
243    /// the parse loop forever after, so there is no guest call on any hot
244    /// path.
245    Language,
246}
247
248impl PluginSeam {
249    /// Where this seam sits in the loader's drain order (OM.0). Lower drains
250    /// first; the loader stable-sorts a plugin's `provides` by this before
251    /// draining, so **the order a manifest happens to list its seams in cannot
252    /// change what registers**.
253    ///
254    /// The ordering exists because seams have real registration dependencies,
255    /// and the one that bites is `modes` on `grammar`: a
256    /// `mode-keymap-binding` resolves its `command` against the
257    /// `CommandRegistry` *at registration*, so a mode binding a chord to the
258    /// plugin's OWN grammar action needs that action already registered. Drain
259    /// the other way round and the binding is skipped with a log — the plugin
260    /// loads "successfully" and the user's chord silently does nothing.
261    ///
262    /// This used to be the plugin author's problem. Both bundled multi-seam
263    /// manifests carried a hand-written comment telling the next author to put
264    /// `grammar` before `modes`, which made a load-bearing invariant depend on
265    /// prose inside a **guest-authored** file. Sorting here makes it a property
266    /// of the seam set instead.
267    ///
268    /// The ranks, and why each is where it is:
269    ///
270    /// | rank | seams | reason |
271    /// |---|---|---|
272    /// | 0 | `config`, `logging` | the option surface: every value any later seam reads exists after this |
273    /// | 1 | `theme` | theme elements later seams may name — and org derives its per-keyword elements from an OPTION, so it must follow rank 0 |
274    /// | 2 | `language` | a major mode may declare `target-language`; the language should exist first |
275    /// | 3 | `grammar` | registers commands into the `CommandRegistry` |
276    /// | 4 | `modes` | keymap bindings resolve command *names* — needs rank 3 |
277    /// | 5 | `keymap` | user-layer bindings, same name resolution |
278    /// | 6 | everything else | producers and data seams with no registration dependency |
279    ///
280    /// Ties keep manifest order (the sort is stable), so an author's intent is
281    /// respected everywhere it cannot break anything.
282    ///
283    /// **OA.14d split `config` from `theme`.** They shared rank 0, so org's
284    /// `register-theme-elements` reading `org.todo-keywords` worked only
285    /// because org's `provides` happened to list `config` first — a
286    /// load-bearing invariant living in a guest-authored file again, which is
287    /// the exact thing this function exists to end. It is also the seam
288    /// boundary the awaited `pre-plugin-loaded` fires on: the loader publishes
289    /// it once every rank-0 seam has drained, so a handler can set an option
290    /// that rank 1 and up will read.
291    pub fn drain_rank(self) -> u8 {
292        match self {
293            PluginSeam::Config | PluginSeam::Logging => 0,
294            PluginSeam::Theme => 1,
295            // After `theme`, because a sign names the element it is painted
296            // in and a plugin normally registers that element itself. Not a
297            // correctness requirement — an unregistered element falls back to
298            // `gutter.sign` rather than to invisibility — but a plugin whose
299            // signs paint in its OWN colours on the first frame is the point.
300            PluginSeam::Signs | PluginSeam::Language => 2,
301            PluginSeam::Grammar => 3,
302            PluginSeam::Modes => 4,
303            PluginSeam::Keymap => 5,
304            PluginSeam::PickerSource
305            | PluginSeam::MultibufferViewSource
306            | PluginSeam::CompletionSource
307            | PluginSeam::Events
308            | PluginSeam::Decorations
309            | PluginSeam::Media
310            | PluginSeam::Context
311            | PluginSeam::PluginManager
312            | PluginSeam::ErrorParser
313            | PluginSeam::ScannedExcerptSource
314            | PluginSeam::Help
315            | PluginSeam::Dashboard
316            // TR.2b: a menu's rows name commands, and the resolution happens
317            // per OPEN rather than at registration — so this seam has no
318            // ordering dependency on `grammar` the way `modes` does. Rank 5
319            // with the other producers.
320            | PluginSeam::TransientSource => 6,
321        }
322    }
323
324    /// The dashed wire / display name (matches the WIT interface).
325    pub fn as_str(self) -> &'static str {
326        match self {
327            PluginSeam::PickerSource => "picker-source",
328            PluginSeam::MultibufferViewSource => "multibuffer-view-source",
329            PluginSeam::CompletionSource => "completion-source",
330            PluginSeam::Grammar => "grammar",
331            PluginSeam::Events => "events",
332            PluginSeam::Modes => "modes",
333            PluginSeam::Config => "config",
334            PluginSeam::Decorations => "decorations",
335            PluginSeam::Media => "media",
336            PluginSeam::Context => "context",
337            PluginSeam::Theme => "theme",
338            PluginSeam::Signs => "signs",
339            PluginSeam::Keymap => "keymap",
340            PluginSeam::Logging => "logging",
341            PluginSeam::PluginManager => "plugin-manager",
342            PluginSeam::ErrorParser => "error-parser",
343            PluginSeam::ScannedExcerptSource => "scanned-excerpt-source",
344            PluginSeam::TransientSource => "transient-source",
345            PluginSeam::Help => "help",
346            PluginSeam::Dashboard => "dashboard",
347            PluginSeam::Language => "language",
348        }
349    }
350}
351
352impl std::fmt::Display for PluginSeam {
353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354        f.write_str(self.as_str())
355    }
356}
357
358impl FromStr for PluginSeam {
359    type Err = ();
360
361    fn from_str(s: &str) -> Result<Self, Self::Err> {
362        Ok(match s {
363            "picker-source" => PluginSeam::PickerSource,
364            "multibuffer-view-source" => PluginSeam::MultibufferViewSource,
365            "completion-source" => PluginSeam::CompletionSource,
366            "grammar" => PluginSeam::Grammar,
367            "events" => PluginSeam::Events,
368            "modes" => PluginSeam::Modes,
369            "config" => PluginSeam::Config,
370            "decorations" => PluginSeam::Decorations,
371            "media" => PluginSeam::Media,
372            "context" => PluginSeam::Context,
373            "theme" => PluginSeam::Theme,
374            "signs" => PluginSeam::Signs,
375            "keymap" => PluginSeam::Keymap,
376            "logging" => PluginSeam::Logging,
377            "plugin-manager" => PluginSeam::PluginManager,
378            "error-parser" => PluginSeam::ErrorParser,
379            "scanned-excerpt-source" => PluginSeam::ScannedExcerptSource,
380            "transient-source" => PluginSeam::TransientSource,
381            "help" => PluginSeam::Help,
382            "dashboard" => PluginSeam::Dashboard,
383            "language" => PluginSeam::Language,
384            _ => return Err(()),
385        })
386    }
387}
388
389/// Everything a plugin declares about itself and what it needs. Untrusted
390/// input; the trust tier is supplied separately at grant time.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct PluginManifest {
393    /// Stable, human-legible plugin id (`fuzzy-finder`). Keys the per-plugin
394    /// data dir (`<config-home>/lattice/plugins/<name>/data/`). Distinct from the
395    /// host-issued numeric [`crate::PluginId`] used for compact provenance.
396    pub id: String,
397    /// The OS capabilities the plugin requests (its WASI view).
398    pub requested: Vec<Capability>,
399    /// The editor capabilities a plugin-declared mode requires (fragment §6).
400    pub editor_capabilities: CapabilitySet,
401    /// The extension seam(s) this plugin's component implements — which
402    /// `spawn_*` path(s) the loader drives. Empty ⇒ lifecycle-only (base
403    /// `plugin` world). Programmatic [`new`](Self::new) defaults this empty;
404    /// disk discovery reads it from the manifest `provides` list.
405    pub provides: Vec<PluginSeam>,
406    /// PI.4: the plugin's own documentation, shown by `:describe-plugin`. A
407    /// static, author-written string (immutable at editor runtime). The
408    /// preferred doc source is the plugin's embedded WIT world doc-comment;
409    /// this manifest field is the fallback when a component ships no WIT docs.
410    pub doc: Option<String>,
411    /// PM.3: the minor modes this plugin enables by DEFAULT. When non-empty the
412    /// loader auto-registers a `<id>.enabled` bool option (default true) that
413    /// gates them — on load (and on any change to the option) the manager
414    /// enables / disables each via the `ModeEnablementRequested` path. Empty ⇒
415    /// every mode the plugin contributes must be enabled explicitly (the CI.3
416    /// available-but-off default). The host learns the mode-ids ONLY from here
417    /// — the plugin owns them (mode-ownership).
418    ///
419    /// **OC.1a made this plural.** A plugin with more than one on-by-default
420    /// mode could not express itself: org contributes `org-todo-mode` (org
421    /// files) *and* `org-global-mode` (the universal `<C-x>o` prefix), and
422    /// naming one left the other registered, correct, and permanently inert —
423    /// a chord that silently does nothing, with the enablement filter the only
424    /// place that would have explained it. The singular `default_mode` key is
425    /// still accepted and folds in here; a manifest may use either or both.
426    pub default_modes: Vec<String>,
427}
428
429/// The on-disk manifest shape. Deserialised first, then validated into
430/// [`PluginManifest`] so parse errors carry the offending string.
431#[derive(Debug, Deserialize, Serialize)]
432struct RawManifest {
433    id: String,
434    #[serde(default)]
435    capabilities: Vec<String>,
436    #[serde(default)]
437    editor_capabilities: Vec<String>,
438    /// PL8.B: the extension seam(s) the component implements (`picker-source`,
439    /// `events`, …). Empty / absent ⇒ lifecycle-only.
440    #[serde(default)]
441    provides: Vec<String>,
442    /// PI.4: the plugin's documentation (`:describe-plugin`).
443    #[serde(default)]
444    doc: Option<String>,
445    /// PM.3: the minor mode this plugin enables by default (gated by
446    /// `<id>.enabled`). Absent ⇒ no default mode. The singular spelling, kept
447    /// because most plugins have exactly one and `default_mode = "x"` reads
448    /// better than a one-element list.
449    #[serde(default)]
450    default_mode: Option<String>,
451    /// OC.1a: the plural spelling, for a plugin with more than one
452    /// on-by-default mode. Merged with `default_mode`; either key alone works.
453    #[serde(default)]
454    default_modes: Vec<String>,
455}
456
457/// Why a manifest failed to parse. Every failure is a value — the host logs +
458/// skips a bad manifest (graceful degradation), never panics.
459#[derive(Debug, thiserror::Error)]
460pub enum ManifestError {
461    /// The TOML itself was malformed or missing a required field.
462    #[error("failed to parse plugin manifest TOML")]
463    Toml(#[from] toml::de::Error),
464
465    /// A `capabilities` entry was not a recognised capability form.
466    #[error(transparent)]
467    Capability(#[from] CapabilityParseError),
468
469    /// An `editor_capabilities` entry was not a recognised editor-capability
470    /// name.
471    #[error(
472        "unrecognised editor capability `{0}` (expected buffer-uri / lsp / tree-sitter / folds / writable / diagnostics)"
473    )]
474    EditorCapability(String),
475
476    /// The `id` field was empty — a plugin must have a non-empty id (it keys
477    /// the data dir path).
478    #[error("plugin manifest `id` must not be empty")]
479    EmptyId,
480
481    /// The `id` field was not a single, safe path component. The id keys the
482    /// per-plugin on-disk data dir (joined into a path + mounted WRITABLE into
483    /// the guest), so a `/`, `\`, `.`, `..`, or absolute id would let a crafted
484    /// manifest escape its sandbox and write outside the data dir. Rejected.
485    #[error(
486        "plugin manifest `id` `{0}` must be a single path component (no `/`, `\\`, `.`, `..`, or absolute path)"
487    )]
488    InvalidId(String),
489
490    /// A `provides` entry was not a recognised seam name.
491    #[error(
492        "unrecognised plugin seam `{0}` (expected picker-source / completion-source / grammar / events / modes / config / decorations / keymap)"
493    )]
494    Seam(String),
495}
496
497/// Is `id` a single, safe path component — usable as a directory name that
498/// cannot escape its parent? The plugin id keys the per-plugin data dir, which
499/// is joined into a host path and mounted writable into the guest; a crafted id
500/// (`/etc/cron.d`, `../../.ssh`, `.`) would otherwise relocate that writable
501/// mount outside the sandbox with no fs grant (the CRITICAL isolation contract,
502/// lib.rs `build_plugin_wasi`). Accepts exactly one `Component::Normal`; rejects
503/// empty, absolute, separators, and `.` / `..`.
504pub fn is_safe_plugin_id(id: &str) -> bool {
505    use std::path::Component;
506    if id.trim().is_empty() {
507        return false;
508    }
509    let path = std::path::Path::new(id);
510    if path.is_absolute() {
511        return false;
512    }
513    let mut comps = path.components();
514    matches!(
515        (comps.next(), comps.next()),
516        (Some(Component::Normal(_)), None)
517    )
518}
519
520impl PluginManifest {
521    /// Construct a manifest programmatically (the host's typed entry point).
522    pub fn new(
523        id: impl Into<String>,
524        requested: Vec<Capability>,
525        editor_capabilities: CapabilitySet,
526    ) -> Self {
527        Self {
528            id: id.into(),
529            requested,
530            editor_capabilities,
531            provides: Vec::new(),
532            doc: None,
533            default_modes: Vec::new(),
534        }
535    }
536
537    /// Parse a manifest from its TOML text, validating every capability form.
538    /// A malformed capability / editor-capability / empty id is a typed
539    /// [`ManifestError`], never a panic.
540    pub fn from_toml_str(text: &str) -> Result<Self, ManifestError> {
541        let raw: RawManifest = toml::from_str(text)?;
542        if raw.id.trim().is_empty() {
543            return Err(ManifestError::EmptyId);
544        }
545        // SECURITY (isolation): the id keys the writable per-plugin data mount, so
546        // an untrusted on-disk manifest MUST NOT carry a path-escaping id.
547        if !is_safe_plugin_id(&raw.id) {
548            return Err(ManifestError::InvalidId(raw.id));
549        }
550        let requested = raw
551            .capabilities
552            .iter()
553            .map(|c| Capability::from_str(c))
554            .collect::<Result<Vec<_>, _>>()?;
555        let mut editor = CapabilitySet::empty();
556        for name in &raw.editor_capabilities {
557            editor |= parse_editor_capability(name)
558                .ok_or_else(|| ManifestError::EditorCapability(name.clone()))?;
559        }
560        let provides = raw
561            .provides
562            .iter()
563            .map(|s| PluginSeam::from_str(s).map_err(|()| ManifestError::Seam(s.clone())))
564            .collect::<Result<Vec<_>, _>>()?;
565        Ok(Self {
566            id: raw.id,
567            requested,
568            editor_capabilities: editor,
569            provides,
570            doc: raw.doc.filter(|d| !d.trim().is_empty()),
571            // Singular first so `default_mode` keeps naming the primary mode,
572            // then the list. Blanks dropped and duplicates collapsed: a
573            // manifest that says the same mode both ways must not publish two
574            // enablement requests for it.
575            default_modes: raw
576                .default_mode
577                .into_iter()
578                .chain(raw.default_modes)
579                .map(|m| m.trim().to_string())
580                .filter(|m| !m.is_empty())
581                .fold(Vec::new(), |mut acc, m| {
582                    if !acc.contains(&m) {
583                        acc.push(m);
584                    }
585                    acc
586                }),
587        })
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    #![allow(clippy::unwrap_used, clippy::panic)]
594
595    use super::*;
596
597    /// PI.4: the optional `doc` field parses (the `:describe-plugin` fallback
598    /// doc source); absent or blank → `None`.
599    #[test]
600    fn parses_optional_doc_field() {
601        let m = PluginManifest::from_toml_str(
602            "id = \"git-gutter\"\ndoc = \"Shows git diff signs in the gutter.\"\n",
603        )
604        .unwrap();
605        assert_eq!(
606            m.doc.as_deref(),
607            Some("Shows git diff signs in the gutter.")
608        );
609
610        let none = PluginManifest::from_toml_str("id = \"x\"\n").unwrap();
611        assert!(none.doc.is_none());
612
613        let blank = PluginManifest::from_toml_str("id = \"x\"\ndoc = \"   \"\n").unwrap();
614        assert!(blank.doc.is_none(), "blank doc is normalised to None");
615    }
616
617    /// PM.3: `default_mode` parses (the `<id>.enabled` gate trigger); absent or
618    /// blank → empty.
619    #[test]
620    fn parses_optional_default_mode() {
621        let m = PluginManifest::from_toml_str(
622            "id = \"auto-pair\"\ndefault_mode = \"auto-pair-mode\"\n",
623        )
624        .unwrap();
625        assert_eq!(m.default_modes, vec!["auto-pair-mode".to_string()]);
626        assert!(
627            PluginManifest::from_toml_str("id = \"x\"\n")
628                .unwrap()
629                .default_modes
630                .is_empty()
631        );
632        assert!(
633            PluginManifest::from_toml_str("id = \"x\"\ndefault_mode = \" \"\n")
634                .unwrap()
635                .default_modes
636                .is_empty(),
637            "blank default_mode normalises away"
638        );
639    }
640
641    /// OC.1a: the plural spelling, and the merge of the two.
642    ///
643    /// The blocker this fixes: a plugin with two on-by-default modes could
644    /// name only one, and the other stayed registered, correct, and inert —
645    /// the chord simply did nothing, with no message anywhere.
646    #[test]
647    fn parses_plural_default_modes_and_merges_them_with_the_singular() {
648        let m = PluginManifest::from_toml_str(
649            "id = \"org\"\ndefault_modes = [\"org-todo-mode\", \"org-global-mode\"]\n",
650        )
651        .unwrap();
652        assert_eq!(
653            m.default_modes,
654            vec!["org-todo-mode".to_string(), "org-global-mode".to_string()]
655        );
656
657        // Both keys: the singular stays first (it names the primary mode).
658        let m = PluginManifest::from_toml_str(
659            "id = \"org\"\ndefault_mode = \"org-todo-mode\"\n\
660             default_modes = [\"org-global-mode\"]\n",
661        )
662        .unwrap();
663        assert_eq!(
664            m.default_modes,
665            vec!["org-todo-mode".to_string(), "org-global-mode".to_string()]
666        );
667
668        // Saying the same mode both ways must not request its enablement
669        // twice, and blanks in the list are dropped like the singular's.
670        let m = PluginManifest::from_toml_str(
671            "id = \"org\"\ndefault_mode = \"a\"\ndefault_modes = [\"a\", \" \", \"b\"]\n",
672        )
673        .unwrap();
674        assert_eq!(m.default_modes, vec!["a".to_string(), "b".to_string()]);
675    }
676
677    /// PM.4: the SHIPPED `auto-pair` manifest declares its `default_mode` (+ the
678    /// `tree-sitter` capability), so it enables out of the box via the gate. Reads
679    /// the real manifest from the repo — a regression guard on the shipped file.
680    #[test]
681    fn shipped_auto_pair_manifest_declares_the_gate() {
682        let path = concat!(
683            env!("CARGO_MANIFEST_DIR"),
684            "/../../plugins/auto-pair/plugin.toml"
685        );
686        let text = std::fs::read_to_string(path).expect("the auto-pair manifest exists");
687        let m = PluginManifest::from_toml_str(&text).expect("it parses");
688        assert_eq!(m.id, "auto-pair");
689        assert_eq!(
690            m.default_modes,
691            vec!["auto-pair-mode".to_string()],
692            "the shipped manifest enables auto-pair-mode by default"
693        );
694        assert!(
695            m.editor_capabilities
696                .contains(lattice_mode::CapabilitySet::TREE_SITTER),
697            "the shipped manifest declares the tree-sitter capability (manual style)"
698        );
699    }
700
701    #[test]
702    fn parses_each_capability_form() {
703        assert_eq!(
704            "fs:read:/home/alice/project".parse::<Capability>().unwrap(),
705            Capability::FsRead(PathBuf::from("/home/alice/project"))
706        );
707        assert_eq!(
708            "fs:write:/tmp/out".parse::<Capability>().unwrap(),
709            Capability::FsWrite(PathBuf::from("/tmp/out"))
710        );
711        assert_eq!(
712            "net:http:crates.io".parse::<Capability>().unwrap(),
713            Capability::NetHttp("crates.io".to_string())
714        );
715        assert_eq!(
716            "proc:spawn".parse::<Capability>().unwrap(),
717            Capability::ProcSpawn
718        );
719    }
720
721    #[test]
722    fn capability_prefix_may_contain_colons() {
723        // A `host:port` net entry and a path with a `:` both survive whole.
724        assert_eq!(
725            "net:http:localhost:8080".parse::<Capability>().unwrap(),
726            Capability::NetHttp("localhost:8080".to_string())
727        );
728        assert_eq!(
729            "fs:read:/weird:path".parse::<Capability>().unwrap(),
730            Capability::FsRead(PathBuf::from("/weird:path"))
731        );
732    }
733
734    #[test]
735    fn display_round_trips_parse() {
736        for s in [
737            "fs:read:/a/b",
738            "fs:write:/c",
739            "net:http:example.com",
740            "proc:spawn",
741        ] {
742            let cap: Capability = s.parse().unwrap();
743            assert_eq!(cap.to_string(), s);
744        }
745    }
746
747    #[test]
748    fn a_safe_id_is_a_single_normal_component() {
749        for ok in [
750            "git-gutter",
751            "fuzzy_finder",
752            "a.b.c",
753            "plugin123",
754            "with space",
755        ] {
756            assert!(is_safe_plugin_id(ok), "`{ok}` should be accepted");
757        }
758    }
759
760    #[test]
761    fn a_path_escaping_id_is_rejected() {
762        // SECURITY: each of these, joined into the writable data-mount path, would
763        // escape the sandbox — they MUST be rejected (the CRITICAL isolation fix).
764        for bad in [
765            "",
766            "   ",
767            "/etc/cron.d",      // absolute → base discarded
768            "../../../.ssh",    // traversal
769            "..",               // parent
770            ".",                // current
771            "a/b",              // separator (nested)
772            "sub/../../escape", // mixed
773            "/",                // root
774        ] {
775            assert!(!is_safe_plugin_id(bad), "`{bad}` must be rejected");
776        }
777    }
778
779    #[test]
780    fn from_toml_rejects_a_path_escaping_id() {
781        // The untrusted on-disk parse path refuses a crafted id with a typed error.
782        for bad in ["/etc/cron.d", "../../victim", ".."] {
783            let toml = format!("id = \"{bad}\"\n");
784            assert!(
785                matches!(
786                    PluginManifest::from_toml_str(&toml),
787                    Err(ManifestError::InvalidId(_))
788                ),
789                "`{bad}` must parse to InvalidId, not load"
790            );
791        }
792        // A normal id still parses.
793        assert!(PluginManifest::from_toml_str("id = \"git-gutter\"\n").is_ok());
794    }
795
796    #[test]
797    fn plugin_seam_as_str_round_trips_from_str_for_every_variant() {
798        // Every variant's wire word parses back to itself — pins the `as_str` /
799        // `from_str` symmetry the trace-record + manifest paths both rely on.
800        for seam in [
801            PluginSeam::PickerSource,
802            PluginSeam::CompletionSource,
803            PluginSeam::Grammar,
804            PluginSeam::Events,
805            PluginSeam::Modes,
806            PluginSeam::Config,
807            PluginSeam::Decorations,
808            PluginSeam::Media,
809            PluginSeam::Keymap,
810            PluginSeam::Logging,
811            // The list had drifted behind the enum — it named eleven of the
812            // eighteen while claiming every variant, so seven seams' wire
813            // words were unpinned. Completed while adding `transient-source`
814            // rather than adding a twelfth to a list that lies.
815            PluginSeam::Context,
816            PluginSeam::Theme,
817            PluginSeam::Signs,
818            PluginSeam::PluginManager,
819            PluginSeam::ErrorParser,
820            PluginSeam::Help,
821            PluginSeam::Dashboard,
822            PluginSeam::ScannedExcerptSource,
823            PluginSeam::Language,
824            PluginSeam::TransientSource,
825        ] {
826            assert_eq!(PluginSeam::from_str(seam.as_str()), Ok(seam));
827        }
828        assert_eq!(PluginSeam::from_str("logging"), Ok(PluginSeam::Logging));
829        assert!(PluginSeam::from_str("nonsense").is_err());
830    }
831
832    #[test]
833    fn malformed_capabilities_are_rejected() {
834        for s in [
835            "",
836            "fs",
837            "fs:read",      // no prefix
838            "fs:read:",     // empty prefix
839            "fs:exec:/a",   // unknown verb
840            "net:http:",    // empty host
841            "proc:kill",    // unknown proc verb
842            "proc:spawn:x", // trailing junk
843            "garbage",
844        ] {
845            assert!(
846                s.parse::<Capability>().is_err(),
847                "expected `{s}` to be rejected"
848            );
849        }
850    }
851
852    #[test]
853    fn manifest_toml_round_trips() {
854        let text = r#"
855            id = "fuzzy-finder"
856            capabilities = ["fs:read:/home/alice/project", "net:http:crates.io"]
857            editor_capabilities = ["tree-sitter", "lsp"]
858        "#;
859        let m = PluginManifest::from_toml_str(text).unwrap();
860        assert_eq!(m.id, "fuzzy-finder");
861        assert_eq!(
862            m.requested,
863            vec![
864                Capability::FsRead(PathBuf::from("/home/alice/project")),
865                Capability::NetHttp("crates.io".to_string()),
866            ]
867        );
868        assert_eq!(
869            m.editor_capabilities,
870            CapabilitySet::TREE_SITTER | CapabilitySet::LSP
871        );
872    }
873
874    #[test]
875    fn manifest_defaults_to_no_capabilities() {
876        let m = PluginManifest::from_toml_str(r#"id = "bare""#).unwrap();
877        assert!(m.requested.is_empty());
878        assert_eq!(m.editor_capabilities, CapabilitySet::empty());
879    }
880
881    #[test]
882    fn manifest_rejects_empty_id() {
883        assert!(matches!(
884            PluginManifest::from_toml_str(r#"id = "  ""#),
885            Err(ManifestError::EmptyId)
886        ));
887    }
888
889    #[test]
890    fn manifest_rejects_bad_capability() {
891        let text = r#"
892            id = "x"
893            capabilities = ["fs:teleport:/a"]
894        "#;
895        assert!(matches!(
896            PluginManifest::from_toml_str(text),
897            Err(ManifestError::Capability(_))
898        ));
899    }
900
901    #[test]
902    fn manifest_rejects_bad_editor_capability() {
903        let text = r#"
904            id = "x"
905            editor_capabilities = ["telepathy"]
906        "#;
907        assert!(matches!(
908            PluginManifest::from_toml_str(text),
909            Err(ManifestError::EditorCapability(_))
910        ));
911    }
912
913    /// OM.0 — the dependency the whole sort exists for. A mode's keymap
914    /// binding resolves its command name at registration, so `grammar` must
915    /// drain before `modes`.
916    #[test]
917    fn grammar_drains_before_modes() {
918        assert!(PluginSeam::Grammar.drain_rank() < PluginSeam::Modes.drain_rank());
919    }
920
921    /// IM.6a: the `media` seam parses from a manifest and round-trips its
922    /// wire name. Pinned because the name is what a plugin author types into
923    /// `plugin.toml`, and a typo there is otherwise a silent "my seam never
924    /// ran".
925    #[test]
926    fn the_media_seam_parses_and_round_trips() {
927        let m = PluginManifest::from_toml_str("id = \"org\"\nprovides = [\"media\"]\n")
928            .expect("parses");
929        assert_eq!(m.provides, vec![PluginSeam::Media]);
930        assert_eq!(PluginSeam::Media.as_str(), "media");
931        // It is a CONSUMER-rank seam like `decorations`: it produces content
932        // from state other seams declared, so it drains after them.
933        assert_eq!(
934            PluginSeam::Media.drain_rank(),
935            PluginSeam::Decorations.drain_rank()
936        );
937    }
938
939    /// Declarations others reference come first; name-resolving seams come
940    /// after the seam that registers the names.
941    #[test]
942    fn drain_ranks_order_declarations_before_consumers() {
943        for declaring in [PluginSeam::Config, PluginSeam::Theme, PluginSeam::Language] {
944            assert!(
945                declaring.drain_rank() < PluginSeam::Grammar.drain_rank(),
946                "{declaring} declares things later seams read"
947            );
948        }
949        // OA.14d: `theme` READS options (org's per-keyword elements come from
950        // `org.todo-keywords`), so it cannot tie with the seam that declares
951        // them — a tie makes the guest's `provides` order decide, which is
952        // where this bug lived.
953        assert!(PluginSeam::Config.drain_rank() < PluginSeam::Theme.drain_rank());
954        // ...and `language` reads them too (TK.4's generated highlight rules).
955        assert!(PluginSeam::Config.drain_rank() < PluginSeam::Language.drain_rank());
956        // `keymap` binds command names too, so it cannot precede `grammar`.
957        assert!(PluginSeam::Grammar.drain_rank() < PluginSeam::Keymap.drain_rank());
958    }
959
960    /// Sorting must be total and stable — every seam has a rank, and equal
961    /// ranks keep the author's ordering. Guards against a new `PluginSeam`
962    /// variant being added without a thought about where it drains.
963    #[test]
964    fn sorting_is_stable_and_order_independent() {
965        let ranked = |mut v: Vec<PluginSeam>| {
966            v.sort_by_key(|s| s.drain_rank());
967            v
968        };
969        let a = ranked(vec![
970            PluginSeam::Modes,
971            PluginSeam::Config,
972            PluginSeam::Grammar,
973        ]);
974        let b = ranked(vec![
975            PluginSeam::Grammar,
976            PluginSeam::Modes,
977            PluginSeam::Config,
978        ]);
979        assert_eq!(a, b, "two permutations sort to the same drain order");
980        assert_eq!(
981            a,
982            vec![PluginSeam::Config, PluginSeam::Grammar, PluginSeam::Modes]
983        );
984
985        // Equal ranks preserve input order (stability), so an author's
986        // intent survives wherever it cannot break anything.
987        let same_rank = ranked(vec![PluginSeam::Help, PluginSeam::Events]);
988        assert_eq!(same_rank, vec![PluginSeam::Help, PluginSeam::Events]);
989    }
990}
991
992#[cfg(test)]
993mod tilde_grant_tests {
994    #![allow(clippy::unwrap_used)]
995    use super::*;
996
997    /// A `~` in an `fs:` grant expands at the parse.
998    ///
999    /// It does not merely *narrow* the grant when left alone — it VOIDS it. The
1000    /// authorizer compares a canonicalized target against the prefix, and
1001    /// `~/org` canonicalizes to nothing, so every write is refused with "outside
1002    /// the plugin's granted paths". That message is true and useless: it reads
1003    /// exactly like a capability the user never wrote, when in fact they wrote
1004    /// it and it could not be understood.
1005    #[test]
1006    fn a_tilde_in_an_fs_grant_expands_to_the_home_directory() {
1007        let Some(home) = dirs::home_dir() else {
1008            eprintln!("SKIP: no home directory on this machine");
1009            return;
1010        };
1011        assert_eq!(
1012            "fs:write:~/org".parse::<Capability>().unwrap(),
1013            Capability::FsWrite(home.join("org"))
1014        );
1015        assert_eq!(
1016            "fs:read:~/notes".parse::<Capability>().unwrap(),
1017            Capability::FsRead(home.join("notes"))
1018        );
1019    }
1020
1021    /// An absolute grant is untouched — the common case must not move.
1022    #[test]
1023    fn an_absolute_grant_is_unchanged() {
1024        assert_eq!(
1025            "fs:write:/srv/org".parse::<Capability>().unwrap(),
1026            Capability::FsWrite(PathBuf::from("/srv/org"))
1027        );
1028    }
1029
1030    /// `~user` stays verbatim rather than being expanded against OUR home,
1031    /// which would silently grant a path the manifest did not ask for.
1032    #[test]
1033    fn another_users_home_is_not_expanded_into_our_own() {
1034        assert_eq!(
1035            "fs:read:~alice/org".parse::<Capability>().unwrap(),
1036            Capability::FsRead(PathBuf::from("~alice/org"))
1037        );
1038    }
1039}