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}