Skip to main content

lattice_plugin_host/
host_services.rs

1//! The `host-services` guest→host seam (plugin-host.md §5) — PH7.4b.
2//!
3//! The first call direction *into* the host: a plugin asks the host to do
4//! something on its behalf, capability-gated against the plugin's
5//! [`CapabilityGrant`](crate::capability::CapabilityGrant) (PH7.2). This is
6//! distinct from the guest's WASI filesystem view: that view is sandboxed by the
7//! `Store`'s preopens, so a guest cannot reach outside its grant even if it tries.
8//! A host-services call, by contrast, runs **host-side with full host authority**
9//! — the host process is not sandboxed — so the grant check is mandatory *here*,
10//! not delegated to WASI. Enforcing it is the whole point of the seam.
11//!
12//! PH7.4b lands one function, [`walk_within_grant`], the capability-gated
13//! workspace enumeration the `fuzzy-finder` (PH7.4d) uses to replicate the native
14//! `files` picker. It reuses the native walker's policy so a plugin source and a
15//! first-party source enumerate identically. The `Host` trait impl + linker
16//! wiring live in `lib.rs` (next to `PluginState`, which carries the grant); this
17//! module holds the gate + walk logic so it is unit-testable without a `Store`.
18
19use std::path::{Path, PathBuf};
20
21use lattice_protocol::Event as NativeEvent;
22use lattice_protocol::event_registry::register_runtime_event;
23use lattice_runtime::EventBus;
24
25use crate::PluginId;
26use crate::capability::CapabilityGrant;
27
28/// The `register-event` host-service body (PH7.8b) — declare a plugin-defined
29/// event. Stamps the plugin's provenance (`plugin:<id>`) as the source so the
30/// event is attributed to its owner in `:describe-event(s)`, then delegates to
31/// the process-wide [`register_runtime_event`]. Returns `false` (registering
32/// nothing) when `name` would shadow a BUILT-IN event — a plugin must not hijack
33/// a native event's subscribers. Factored here (the [`walk_within_grant`]
34/// precedent) so the provenance formatting is unit-testable without a `Store`.
35pub(crate) fn register_plugin_event(plugin: PluginId, name: &str, doc: &str) -> bool {
36    register_runtime_event(name, doc, format!("plugin:{}", plugin.0))
37}
38
39/// The `emit-event` host-service body (PH7.8b) — publish a plugin-defined event
40/// on `bus`. The host is a thin router: `payload` is opaque MessagePack the
41/// plugin owns; it crosses onto the bus as [`NativeEvent::Plugin`] verbatim and
42/// the host NEVER interprets it. Fire-and-forget (the bus is observation-only,
43/// §5.10): there is no reply. Subscribers filter by `name` in their handler.
44pub(crate) fn emit_plugin_event(bus: &EventBus, name: String, payload: Vec<u8>) {
45    bus.publish(NativeEvent::Plugin { name, payload });
46}
47
48/// The `local-utc-offset-seconds` host-service body (OC.4) — the host's offset
49/// from UTC right now, east-positive.
50///
51/// **Resolved per call, not cached.** The offset is not a constant: it changes
52/// at a DST boundary, and it changes if the user changes their system timezone
53/// while the editor is running. Caching it would make an editor left open
54/// overnight write clock lines an hour wrong for the rest of the session — the
55/// exact bug class this seam exists to prevent, reintroduced as an optimisation
56/// of a call org makes twice a minute.
57///
58/// Not capability-gated (see the WIT): it names no path and reaches no resource,
59/// and gating it would mean a plugin with no filesystem grant renders every
60/// timestamp in the wrong timezone.
61pub(crate) fn local_utc_offset_seconds() -> i32 {
62    chrono::Local::now().offset().local_minus_utc()
63}
64
65/// The `clamp-position` host-service body (CD.6b): `(line, byte)` moved to the
66/// nearest position that exists in `buffer`.
67///
68/// A line past the end becomes the last line. A byte past the end of its line
69/// becomes the end of that line, **before** its newline, so a clamped
70/// insertion never lands on the next line. Both clamps only move a position
71/// backwards, so a range whose start was not after its end still is not.
72///
73/// A byte inside a multi-byte character is left there: `apply-edit` snaps to a
74/// character boundary itself, and doing it twice would disagree about which way.
75pub(crate) fn clamp_position(buffer: &lattice_core::Buffer, line: u32, byte: u32) -> (u32, u32) {
76    // `rope_line_count` counts the empty line after a trailing newline, and an
77    // empty buffer has one line, so it is never zero.
78    let last = buffer.rope_line_count().saturating_sub(1);
79    let line = line.min(last);
80    (line, byte.min(buffer.line_byte_len(line)))
81}
82
83/// The `new-uuid` host-service body (OR.3) — a random (v4) UUID, uppercase,
84/// canonical `8-4-4-4-12` form.
85///
86/// **Host-side for [`read_within_grant`]'s exact reason.** `:org-roam-id-create`
87/// is a grammar action: it runs on the grammar seam's *synchronous* linker,
88/// where `wasmtime-wasi`'s sync shim blocks on a runtime internally and panics
89/// on a thread already inside one. A guest minting its own id through
90/// `wasi:random` would work on the async picker path and take the plugin down on
91/// the grammar path — correct in every test that builds its own context, broken
92/// in the editor.
93///
94/// **Hand-rolled rather than the `uuid` crate**, following the precedent the
95/// workspace already set for `getrandom` (`lattice-ai`'s MCP session token: "a
96/// minimal, vetted CSPRNG primitive, no heavier `rand`/`uuid` dep pulled for one
97/// token"). v4 is sixteen random bytes with six bits pinned; the formatting is
98/// one `write!`. A dependency would buy parsing, versions 1/3/5/7 and a `Uuid`
99/// type, none of which crosses a WIT `string`.
100///
101/// **`Err`, not a degraded value**, and this is the one place on this seam where
102/// that is the right shape. Its neighbours answer `0` when unwired
103/// (`wake-every`, `local-utc-offset-seconds`) on the argument that a legible
104/// wrong answer beats a fabricated one — but those values are *read*. An id is
105/// **written**, into the user's own file, as an `:ID:` that outlives the session
106/// and every other tool's view of that note. A guest handed an empty string on
107/// entropy failure would write an empty drawer and nothing would ever say so.
108/// One `match` at the two call sites buys that being impossible.
109///
110/// Not a panic either: a host function that unwinds through wasm frames aborts
111/// the process, which is a worse answer than a plugin reporting that it could
112/// not mint an id.
113pub(crate) fn new_uuid() -> Result<String, String> {
114    let mut bytes = [0u8; 16];
115    if let Err(error) = getrandom::getrandom(&mut bytes) {
116        // error!: genuinely user-actionable and genuinely one-shot — the OS
117        // entropy source being unavailable is not a per-keystroke condition.
118        tracing::error!(%error, "new-uuid: the OS entropy source is unavailable");
119        return Err(format!("cannot mint an id without entropy: {error}"));
120    }
121    // RFC 4122 §4.4: version 4 in the high nibble of byte 6, variant 10xx in
122    // the two high bits of byte 8. Everything else stays random.
123    bytes[6] = (bytes[6] & 0x0F) | 0x40;
124    bytes[8] = (bytes[8] & 0x3F) | 0x80;
125
126    let hex = |b: &[u8]| -> String { b.iter().map(|x| format!("{x:02X}")).collect() };
127    Ok(format!(
128        "{}-{}-{}-{}-{}",
129        hex(&bytes[0..4]),
130        hex(&bytes[4..6]),
131        hex(&bytes[6..8]),
132        hex(&bytes[8..10]),
133        hex(&bytes[10..16]),
134    ))
135}
136
137/// True if `root` lies within one of the grant's fs prefixes (read *or* write —
138/// a walk only reads). Both sides are canonicalized first so a `..` segment
139/// cannot escape a granted prefix. A `root` that does not exist is resolved
140/// through its nearest existing ancestor, with the unresolved tail normalised
141/// rather than followed — the write gate's rule
142/// ([`crate::effect_authorizer::resolve_for_compare`]), so it can place a new
143/// path correctly without ever widening the grant.
144pub(crate) fn grant_permits_walk(grant: &CapabilityGrant, root: &Path) -> bool {
145    // The write gate's resolver, so a path whose directory does not exist yet
146    // is compared in the same (canonical) terms as the prefix. The raw
147    // fallback this used to take refused such a path whenever the grant sat
148    // behind a symlink — macOS's `/var`, or an org directory linked into a
149    // synced folder — and `delete-file` then refused to discard a capture
150    // draft that had never been saved (CD.6).
151    let canon_root = crate::effect_authorizer::resolve_for_compare(root);
152    grant.fs.iter().any(|g| {
153        let canon_prefix = std::fs::canonicalize(&g.prefix).unwrap_or_else(|_| g.prefix.clone());
154        canon_root.starts_with(&canon_prefix)
155    })
156}
157
158/// The same check for a FILE — on the file itself when it exists, on its parent
159/// only when it does not.
160///
161/// **The file itself first, and that ordering is a security property.**
162/// Canonicalizing resolves symlinks, so `<granted>/innocent.org` pointing at
163/// `/etc/passwd` resolves outside the prefix and is refused. Gating on the
164/// parent alone would pass it — the parent *is* granted — and the read would
165/// then follow the link straight out of the sandbox. Pinned by
166/// `a_symlink_out_of_the_grant_is_denied`, which failed against exactly that
167/// mistake before this ordering existed.
168///
169/// The parent fallback exists only for a path that does not resolve, and there
170/// it fixes a different wrong answer. [`grant_permits_walk`] falls back to the
171/// raw path, and wherever the granted prefix canonicalizes elsewhere (macOS
172/// `/var` → `/private/var`) the comparison fails and the call is refused — so a
173/// file that simply does not exist yet reports as a *permission* problem,
174/// sending a plugin author to their manifest instead of their path. "Is there
175/// anything in this file yet?" is the ordinary first-capture case and deserves
176/// a truthful answer.
177///
178/// The fallback cannot be used to smuggle anything past the check: it only
179/// applies when nothing is there to read, so the subsequent `read` fails
180/// regardless. `<granted>/../../etc/passwd` resolves as a file and is denied on
181/// the first branch.
182pub fn grant_permits_read(grant: &CapabilityGrant, file: &Path) -> bool {
183    if file.exists() {
184        return grant_permits_walk(grant, file);
185    }
186    match file.parent() {
187        Some(parent) if !parent.as_os_str().is_empty() => grant_permits_walk(grant, parent),
188        _ => grant_permits_walk(grant, file),
189    }
190}
191
192/// [`grant_permits_read`], restricted to the plugin's **writable** prefixes.
193///
194/// Same ordering, for the same security reason: the file itself is
195/// canonicalized when it exists, so a symlink under a writable prefix that
196/// points outside it is refused.
197pub fn grant_permits_write(grant: &CapabilityGrant, file: &Path) -> bool {
198    let writable = CapabilityGrant {
199        fs: grant.fs.iter().filter(|g| g.write).cloned().collect(),
200        ..Default::default()
201    };
202    grant_permits_read(&writable, file)
203}
204
205/// CD.3: capability-gated file delete (host-side, §5) — [`read_within_grant`]'s
206/// peer, and host-side for its reason: a grammar action cannot use WASI.
207///
208/// Absence is success. A directory is refused rather than removed: the seam
209/// deletes files, and a plugin that wants a directory gone has asked for
210/// something with a much larger blast radius than its name suggests.
211pub(crate) fn delete_within_grant(grant: &CapabilityGrant, path: &str) -> Result<(), String> {
212    let file = PathBuf::from(path);
213    if !grant_permits_write(grant, &file) {
214        // info!, as `read`'s denial: user-actionable, never per-frame.
215        tracing::info!(
216            path = %file.display(),
217            "host-services delete denied: outside the plugin's writable fs grant"
218        );
219        return Err(format!(
220            "fs delete denied: '{path}' is outside the plugin's writable paths"
221        ));
222    }
223    match std::fs::symlink_metadata(&file) {
224        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
225        Err(e) => return Err(format!("fs delete failed: '{path}': {e}")),
226        Ok(meta) if meta.is_dir() => {
227            return Err(format!("fs delete refused: '{path}' is a directory"));
228        }
229        Ok(_) => {}
230    }
231    match std::fs::remove_file(&file) {
232        Ok(()) => Ok(()),
233        // Gone between the stat and the remove: still the outcome asked for.
234        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
235        Err(e) => Err(format!("fs delete failed: '{path}': {e}")),
236    }
237}
238
239/// CD.3b: would a `WriteToFile` of `path` from this plugin land?
240///
241/// The grant half is [`crate::effect_authorizer::EffectAuthorizer::permits_write`]
242/// itself, not a lookalike, so this answers exactly what the boundary will
243/// decide. The rest mirrors `Editor::resolve_path_to_buffer_creating` (with
244/// `create_parents: false`, capture's setting) and the save a commit makes.
245pub(crate) fn can_write_within_grant(grant: &CapabilityGrant, path: &str) -> Result<(), String> {
246    let file = PathBuf::from(path);
247    if !crate::effect_authorizer::EffectAuthorizer::new(grant, "").permits_write(&file) {
248        return Err(format!(
249            "write denied: '{path}' is outside the plugin's writable paths"
250        ));
251    }
252    match std::fs::metadata(&file) {
253        Ok(meta) if meta.is_dir() => Err(format!("'{path}' is a directory")),
254        Ok(meta) if meta.permissions().readonly() => Err(format!("'{path}' is read-only")),
255        Ok(_) => std::fs::read_to_string(&file)
256            .map(|_| ())
257            .map_err(|e| format!("could not read '{path}': {e}")),
258        Err(e) if e.kind() == std::io::ErrorKind::NotFound => match file.parent() {
259            Some(dir) if dir.as_os_str().is_empty() || dir.is_dir() => Ok(()),
260            Some(dir) => Err(format!("no such directory: {}", dir.display())),
261            None => Err(format!("'{path}' has no parent directory")),
262        },
263        Err(e) => Err(format!("could not inspect '{path}': {e}")),
264    }
265}
266
267/// OC.5a: capability-gated file read (host-side, §5).
268///
269/// The reason this exists rather than the guest using WASI is structural, not a
270/// convenience: the grammar seam runs on a **synchronous** linker so an action
271/// can be called on the dispatch thread, and `wasmtime-wasi`'s sync filesystem
272/// shim blocks on a runtime internally — which panics on a thread already inside
273/// one. A grammar action reading through WASI therefore takes the plugin down
274/// instead of returning bytes. Async seams (pickers, completion) are unaffected;
275/// this is the read that works everywhere.
276///
277/// Same grant check as [`walk_within_grant`], for the same reason: the host has
278/// ambient authority the guest's sandbox would otherwise have bounded.
279///
280/// Three distinct failures rather than one, because a plugin author debugging
281/// "the read failed" needs to know whether to fix their manifest, their path, or
282/// their expectations about the file's encoding.
283pub(crate) fn read_within_grant(grant: &CapabilityGrant, path: &str) -> Result<String, String> {
284    let file = PathBuf::from(path);
285    if !grant_permits_read(grant, &file) {
286        // info!: user-actionable (a plugin was denied fs access), not per-frame
287        // noise — the log-levels rule (CLAUDE.md). Matches `walk`'s level.
288        tracing::info!(
289            path = %file.display(),
290            "host-services read denied: outside the plugin's fs grant"
291        );
292        return Err(format!(
293            "fs read denied: '{path}' is outside the plugin's granted paths"
294        ));
295    }
296    match std::fs::read(&file) {
297        Ok(bytes) => String::from_utf8(bytes)
298            .map_err(|_| format!("fs read failed: '{path}' is not valid UTF-8")),
299        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
300            // Not logged. A caller asking whether a file exists yet — a first
301            // capture into a new file — would otherwise fill the log with
302            // events that are the ordinary case, not a problem.
303            Err(format!("fs read failed: '{path}' does not exist"))
304        }
305        Err(e) => Err(format!("fs read failed: '{path}': {e}")),
306    }
307}
308
309/// Capability-gated workspace walk (host-side, §5). Returns absolute UTF-8 paths
310/// under `root`, applying the native file-picker policy (bounded entry count;
311/// skips `.git`/`target`/`node_modules`/`dist`/`.cache` and dotfiles) so a plugin
312/// source enumerates identically to the first-party `files` source.
313///
314/// `root` must lie within one of the plugin's granted `fs:read`/`fs:write`
315/// prefixes; otherwise the call is a typed `Err` (echoed to the user, §4) and the
316/// denial is logged. A plugin with no fs grant reaches nothing. A non-UTF-8 path
317/// is skipped (it cannot cross as a WIT `string`), never an error — one
318/// oddly-named file must not fail the whole walk.
319pub(crate) fn walk_within_grant(
320    grant: &CapabilityGrant,
321    root: &str,
322) -> Result<Vec<String>, String> {
323    let root_path = PathBuf::from(root);
324    if !grant_permits_walk(grant, &root_path) {
325        // info!: user-actionable (a plugin was denied fs access), not per-frame
326        // noise — the log-levels rule (CLAUDE.md).
327        tracing::info!(
328            path = %root_path.display(),
329            "host-services walk denied: outside the plugin's fs grant"
330        );
331        return Err(format!(
332            "fs walk denied: '{root}' is outside the plugin's granted paths"
333        ));
334    }
335    let paths = lattice_picker::picker_sources::walk_files_for_picker(&root_path);
336    Ok(paths
337        .into_iter()
338        .filter_map(|(p, _meta)| p.to_str().map(str::to_string))
339        .collect())
340}
341
342#[cfg(test)]
343mod tests {
344    #![allow(clippy::unwrap_used, clippy::panic)]
345
346    use super::*;
347    use crate::capability::FsGrant;
348
349    /// CD.6b: the pure clamp, including the cases the seam test cannot reach
350    /// cheaply — an empty buffer, and a line whose byte length is not its
351    /// character count.
352    #[test]
353    fn clamp_position_pulls_a_position_back_into_the_buffer() {
354        let buf = |t: &str| lattice_core::Document::from_text(t).buffer().clone();
355        assert_eq!(clamp_position(&buf(""), 3, 3), (0, 0), "an empty buffer");
356        assert_eq!(
357            clamp_position(&buf("héllo\n"), 0, 99),
358            (0, 6),
359            "bytes, not chars"
360        );
361        assert_eq!(
362            clamp_position(&buf("a\nbc"), 1, 1),
363            (1, 1),
364            "inside: unchanged"
365        );
366        let (start, end) = (
367            clamp_position(&buf("abc\n"), 0, 2),
368            clamp_position(&buf("abc\n"), 5, 9),
369        );
370        assert!(
371            start <= end,
372            "an ordered range stays ordered: {start:?} {end:?}"
373        );
374    }
375
376    /// Build a grant that reads exactly `prefix`.
377    fn read_grant(prefix: PathBuf) -> CapabilityGrant {
378        CapabilityGrant {
379            fs: vec![FsGrant {
380                prefix,
381                write: false,
382            }],
383            ..Default::default()
384        }
385    }
386
387    fn write_grant(prefix: PathBuf) -> CapabilityGrant {
388        CapabilityGrant {
389            fs: vec![FsGrant {
390                prefix,
391                write: true,
392            }],
393            ..Default::default()
394        }
395    }
396
397    #[test]
398    fn can_write_accepts_a_new_file_in_a_granted_directory() {
399        let dir = tempfile::tempdir().unwrap();
400        let grant = write_grant(dir.path().to_path_buf());
401        let new = dir.path().join("inbox.org");
402        assert_eq!(
403            can_write_within_grant(&grant, new.to_str().unwrap()),
404            Ok(())
405        );
406        assert!(!new.exists(), "a query creates nothing");
407    }
408
409    #[test]
410    fn can_write_accepts_an_existing_writable_file() {
411        let dir = tempfile::tempdir().unwrap();
412        let file = dir.path().join("inbox.org");
413        std::fs::write(&file, "* One\n").unwrap();
414        let grant = write_grant(dir.path().to_path_buf());
415        assert_eq!(
416            can_write_within_grant(&grant, file.to_str().unwrap()),
417            Ok(())
418        );
419    }
420
421    /// Each refusal says which check failed — "cannot write" alone sends the
422    /// user to guess between their grant, their path and their file.
423    #[test]
424    fn can_write_names_each_refusal() {
425        let dir = tempfile::tempdir().unwrap();
426        let other = tempfile::tempdir().unwrap();
427        let grant = write_grant(dir.path().to_path_buf());
428        let check = |p: &std::path::Path| can_write_within_grant(&grant, p.to_str().unwrap());
429
430        let outside = other.path().join("x.org");
431        assert!(check(&outside).unwrap_err().contains("outside"));
432
433        let read_only_grant = read_grant(dir.path().to_path_buf());
434        let err =
435            can_write_within_grant(&read_only_grant, dir.path().join("x.org").to_str().unwrap())
436                .unwrap_err();
437        assert!(
438            err.contains("outside"),
439            "a read grant is not a write grant: {err}"
440        );
441
442        let sub = dir.path().join("sub");
443        std::fs::create_dir(&sub).unwrap();
444        assert!(check(&sub).unwrap_err().contains("directory"));
445
446        let missing_dir = dir.path().join("nope").join("x.org");
447        assert!(
448            check(&missing_dir)
449                .unwrap_err()
450                .contains("no such directory")
451        );
452
453        let binary = dir.path().join("bin.org");
454        std::fs::write(&binary, [0xff, 0xfe]).unwrap();
455        assert!(check(&binary).unwrap_err().contains("could not read"));
456
457        let locked = dir.path().join("locked.org");
458        std::fs::write(&locked, "x").unwrap();
459        let mut perms = std::fs::metadata(&locked).unwrap().permissions();
460        perms.set_readonly(true);
461        std::fs::set_permissions(&locked, perms).unwrap();
462        assert!(check(&locked).unwrap_err().contains("read-only"));
463    }
464
465    #[test]
466    fn delete_file_removes_a_file_within_a_writable_grant() {
467        let dir = tempfile::tempdir().unwrap();
468        let file = dir.path().join("draft.org");
469        std::fs::write(&file, "x").unwrap();
470        let grant = write_grant(dir.path().to_path_buf());
471        delete_within_grant(&grant, file.to_str().unwrap()).unwrap();
472        assert!(!file.exists());
473    }
474
475    /// A draft never saved, in a drafts directory not yet created, under a
476    /// grant that is reached through a symlink: nothing to delete, so `ok`.
477    ///
478    /// It was refused. Neither the file nor its directory resolved, so the
479    /// raw path was compared with the CANONICAL prefix and did not match.
480    /// macOS's `/var` → `/private/var` made every temp-dir test hit it, and an
481    /// org directory linked into a synced folder hits it for real.
482    #[cfg(unix)]
483    #[test]
484    fn deleting_under_a_symlinked_grant_with_a_missing_directory_is_ok() {
485        let real = tempfile::tempdir().unwrap();
486        let links = tempfile::tempdir().unwrap();
487        let link = links.path().join("org");
488        std::os::unix::fs::symlink(real.path(), &link).unwrap();
489        let grant = write_grant(link.clone());
490        let draft = link.join("captures").join("a3f9c1.org");
491        assert_eq!(delete_within_grant(&grant, draft.to_str().unwrap()), Ok(()));
492
493        // And the resolution still cannot climb out of the grant.
494        let escape = link
495            .join("captures")
496            .join("..")
497            .join("..")
498            .join("elsewhere.org");
499        assert!(delete_within_grant(&grant, escape.to_str().unwrap()).is_err());
500    }
501
502    /// Discarding a capture that was never saved deletes nothing, and that
503    /// is success, not an error the guest has to special-case.
504    #[test]
505    fn deleting_an_absent_file_is_ok() {
506        let dir = tempfile::tempdir().unwrap();
507        let grant = write_grant(dir.path().to_path_buf());
508        let absent = dir.path().join("never-saved.org");
509        assert_eq!(
510            delete_within_grant(&grant, absent.to_str().unwrap()),
511            Ok(())
512        );
513    }
514
515    #[test]
516    fn a_read_grant_does_not_permit_deleting() {
517        let dir = tempfile::tempdir().unwrap();
518        let file = dir.path().join("keep.org");
519        std::fs::write(&file, "x").unwrap();
520        let grant = read_grant(dir.path().to_path_buf());
521        let err = delete_within_grant(&grant, file.to_str().unwrap()).unwrap_err();
522        assert!(err.contains("denied"), "{err}");
523        assert!(file.exists());
524    }
525
526    #[test]
527    fn deleting_outside_the_grant_is_denied() {
528        let granted = tempfile::tempdir().unwrap();
529        let other = tempfile::tempdir().unwrap();
530        let file = other.path().join("theirs.org");
531        std::fs::write(&file, "x").unwrap();
532        let grant = write_grant(granted.path().to_path_buf());
533        assert!(delete_within_grant(&grant, file.to_str().unwrap()).is_err());
534        assert!(file.exists());
535    }
536
537    /// A link under the writable prefix that points outside it resolves
538    /// outside, and is refused — the target survives.
539    #[cfg(unix)]
540    #[test]
541    fn a_symlink_out_of_the_writable_grant_is_denied() {
542        let granted = tempfile::tempdir().unwrap();
543        let other = tempfile::tempdir().unwrap();
544        let target = other.path().join("precious.org");
545        std::fs::write(&target, "x").unwrap();
546        let link = granted.path().join("link.org");
547        std::os::unix::fs::symlink(&target, &link).unwrap();
548        let grant = write_grant(granted.path().to_path_buf());
549        assert!(delete_within_grant(&grant, link.to_str().unwrap()).is_err());
550        assert!(target.exists());
551        assert!(link.exists());
552    }
553
554    #[test]
555    fn a_directory_is_refused() {
556        let dir = tempfile::tempdir().unwrap();
557        let sub = dir.path().join("captures");
558        std::fs::create_dir(&sub).unwrap();
559        let grant = write_grant(dir.path().to_path_buf());
560        let err = delete_within_grant(&grant, sub.to_str().unwrap()).unwrap_err();
561        assert!(err.contains("directory"), "{err}");
562        assert!(sub.exists());
563    }
564
565    #[test]
566    fn walk_returns_files_within_grant() {
567        let dir = tempfile::tempdir().unwrap();
568        std::fs::write(dir.path().join("a.rs"), "").unwrap();
569        std::fs::write(dir.path().join("b.rs"), "").unwrap();
570        std::fs::create_dir(dir.path().join("sub")).unwrap();
571        std::fs::write(dir.path().join("sub/c.rs"), "").unwrap();
572
573        let grant = read_grant(dir.path().to_path_buf());
574        let out = walk_within_grant(&grant, dir.path().to_str().unwrap()).unwrap();
575
576        assert_eq!(out.len(), 3, "walks recursively: {out:?}");
577        assert!(out.iter().all(|p| p.ends_with(".rs")));
578        // Component-wise: the walk returns native paths (`\` on Windows).
579        assert!(out.iter().any(|p| Path::new(p).ends_with("sub/c.rs")));
580    }
581
582    #[test]
583    fn read_file_returns_the_contents_within_grant() {
584        let dir = tempfile::tempdir().unwrap();
585        let file = dir.path().join("notes.org");
586        std::fs::write(&file, "* Tasks\nbody\n").unwrap();
587
588        let grant = read_grant(dir.path().to_path_buf());
589        let out = read_within_grant(&grant, file.to_str().unwrap()).unwrap();
590        assert_eq!(out, "* Tasks\nbody\n");
591    }
592
593    /// Same gate as `walk`, and it matters more here: `read-file` returns file
594    /// CONTENTS, so a missing check leaks the contents of any file the editor
595    /// process can reach.
596    #[test]
597    fn read_file_outside_the_grant_is_a_typed_error() {
598        let granted = tempfile::tempdir().unwrap();
599        let other = tempfile::tempdir().unwrap();
600        let secret = other.path().join("secret");
601        std::fs::write(&secret, "private").unwrap();
602
603        let grant = read_grant(granted.path().to_path_buf());
604        let err = read_within_grant(&grant, secret.to_str().unwrap())
605            .expect_err("a path outside the grant must be denied");
606        assert!(err.contains("denied"), "error explains the denial: {err}");
607        assert!(
608            !err.contains("private"),
609            "and the denial does not leak what it refused to read: {err}"
610        );
611    }
612
613    /// **A symlink inside the granted directory must not read outside it.**
614    ///
615    /// The gate canonicalizes so that a path resolving out of the grant is
616    /// refused; gating on the parent directory alone would pass this — the
617    /// parent IS granted — and then `read` would follow the link. That is a
618    /// capability bypass, not a cosmetic bug: the whole point of the grant is
619    /// that a plugin reaches only what it was given.
620    #[cfg(unix)]
621    #[test]
622    fn a_symlink_out_of_the_grant_is_denied() {
623        let granted = tempfile::tempdir().unwrap();
624        let other = tempfile::tempdir().unwrap();
625        let secret = other.path().join("secret");
626        std::fs::write(&secret, "private").unwrap();
627
628        let link = granted.path().join("innocent.org");
629        std::os::unix::fs::symlink(&secret, &link).unwrap();
630
631        let grant = read_grant(granted.path().to_path_buf());
632        let err = read_within_grant(&grant, link.to_str().unwrap())
633            .expect_err("a symlink escaping the grant must be denied");
634        assert!(err.contains("denied"), "{err}");
635        assert!(!err.contains("private"), "and leaks nothing: {err}");
636    }
637
638    /// A symlink that stays INSIDE the grant is fine — the check is about where
639    /// the target lands, not about symlinks being suspicious.
640    #[cfg(unix)]
641    #[test]
642    fn a_symlink_within_the_grant_is_permitted() {
643        let granted = tempfile::tempdir().unwrap();
644        let real = granted.path().join("real.org");
645        std::fs::write(&real, "* Tasks\n").unwrap();
646        let link = granted.path().join("alias.org");
647        std::os::unix::fs::symlink(&real, &link).unwrap();
648
649        let grant = read_grant(granted.path().to_path_buf());
650        assert_eq!(
651            read_within_grant(&grant, link.to_str().unwrap()).unwrap(),
652            "* Tasks\n"
653        );
654    }
655
656    /// A plugin with no fs grant reads nothing — the default posture.
657    #[test]
658    fn read_file_with_an_empty_grant_reaches_nothing() {
659        let dir = tempfile::tempdir().unwrap();
660        let file = dir.path().join("a.org");
661        std::fs::write(&file, "x").unwrap();
662
663        let grant = CapabilityGrant::default();
664        assert!(read_within_grant(&grant, file.to_str().unwrap()).is_err());
665    }
666
667    /// Absence is distinguishable from denial. A caller for whom "not there
668    /// yet" is an ordinary case — a first capture into a new file — must be
669    /// able to tell it apart from a manifest it needs to fix.
670    #[test]
671    fn a_missing_file_says_so_rather_than_reading_as_denied() {
672        let dir = tempfile::tempdir().unwrap();
673        let grant = read_grant(dir.path().to_path_buf());
674        let err = read_within_grant(&grant, dir.path().join("nope.org").to_str().unwrap())
675            .expect_err("a missing file is an error");
676        assert!(err.contains("does not exist"), "{err}");
677        assert!(
678            !err.contains("denied"),
679            "and is NOT reported as a denial: {err}"
680        );
681    }
682
683    /// A write grant implies read — it is the same directory, opened with
684    /// `READ | MUTATE`. Org relies on this: capture already needs `fs:write`
685    /// for the file it is about to append to, and OC.5a reads that same file to
686    /// find the headline. Requiring a second grant for it would be ceremony.
687    #[test]
688    fn a_write_grant_also_permits_reading() {
689        let dir = tempfile::tempdir().unwrap();
690        let file = dir.path().join("notes.org");
691        std::fs::write(&file, "* Tasks\n").unwrap();
692
693        let grant = CapabilityGrant {
694            fs: vec![FsGrant {
695                prefix: dir.path().to_path_buf(),
696                write: true,
697            }],
698            ..Default::default()
699        };
700        assert_eq!(
701            read_within_grant(&grant, file.to_str().unwrap()).unwrap(),
702            "* Tasks\n"
703        );
704    }
705
706    #[test]
707    fn walk_outside_the_grant_is_a_typed_error() {
708        let granted = tempfile::tempdir().unwrap();
709        let other = tempfile::tempdir().unwrap();
710        std::fs::write(other.path().join("secret"), "").unwrap();
711
712        let grant = read_grant(granted.path().to_path_buf());
713        let err = walk_within_grant(&grant, other.path().to_str().unwrap())
714            .expect_err("a path outside the grant must be denied");
715        assert!(err.contains("denied"), "error explains the denial: {err}");
716    }
717
718    #[test]
719    fn empty_grant_reaches_nothing() {
720        let dir = tempfile::tempdir().unwrap();
721        std::fs::write(dir.path().join("a.rs"), "").unwrap();
722        // A plugin with no fs grant (the default) can walk nothing.
723        let err = walk_within_grant(&CapabilityGrant::default(), dir.path().to_str().unwrap())
724            .expect_err("no grant reaches nothing");
725        assert!(err.contains("denied"));
726    }
727
728    #[test]
729    fn walk_applies_the_native_ignore_policy() {
730        let dir = tempfile::tempdir().unwrap();
731        std::fs::write(dir.path().join("keep.rs"), "").unwrap();
732        std::fs::create_dir(dir.path().join(".git")).unwrap();
733        std::fs::write(dir.path().join(".git/HEAD"), "").unwrap();
734        std::fs::create_dir(dir.path().join("target")).unwrap();
735        std::fs::write(dir.path().join("target/out"), "").unwrap();
736
737        let grant = read_grant(dir.path().to_path_buf());
738        let out = walk_within_grant(&grant, dir.path().to_str().unwrap()).unwrap();
739
740        assert!(out.iter().any(|p| p.ends_with("keep.rs")));
741        assert!(
742            !out.iter()
743                .any(|p| p.contains(".git") || p.contains("target")),
744            "ignore dirs are skipped host-side: {out:?}"
745        );
746    }
747
748    #[test]
749    fn register_plugin_event_stamps_the_plugin_provenance() {
750        use lattice_protocol::event_registry::{event_info_by_name, unregister_runtime_event};
751
752        // A fresh, uniquely-named event registers and carries the plugin's
753        // provenance (`plugin:<id>`) as its source — the piece host_services adds
754        // on top of the registry (built-in-shadow rejection is `register_runtime_event`'s
755        // own contract, covered in `event_registry`).
756        let name = "host-services-test.custom-event";
757        assert!(register_plugin_event(PluginId(42), name, "a test event"));
758        let info = event_info_by_name(name).expect("registered");
759        assert_eq!(info.source, "plugin:42");
760        assert!(!info.builtin, "a plugin event is not a built-in");
761        assert_eq!(info.doc, "a test event");
762        unregister_runtime_event(name);
763    }
764
765    #[test]
766    fn emit_plugin_event_publishes_to_a_native_subscriber() {
767        use lattice_protocol::EventKind;
768        use lattice_runtime::{EventFilter, SubscriptionTarget};
769
770        let bus = EventBus::new();
771        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
772        bus.subscribe(
773            EventFilter::kind(EventKind::Plugin),
774            SubscriptionTarget::Channel(tx),
775        );
776
777        emit_plugin_event(&bus, "my-plugin.indexed".into(), vec![1, 2, 3]);
778
779        match rx.try_recv() {
780            Ok(NativeEvent::Plugin { name, payload }) => {
781                assert_eq!(name, "my-plugin.indexed");
782                assert_eq!(payload, vec![1, 2, 3], "opaque bytes cross verbatim");
783            }
784            other => panic!("expected a Plugin event, got {other:?}"),
785        }
786    }
787
788    #[test]
789    fn a_subdirectory_of_a_granted_prefix_is_permitted() {
790        let dir = tempfile::tempdir().unwrap();
791        std::fs::create_dir(dir.path().join("src")).unwrap();
792        std::fs::write(dir.path().join("src/main.rs"), "").unwrap();
793        // Grant the parent; walk a child — starts_with permits it.
794        let grant = read_grant(dir.path().to_path_buf());
795        let out = walk_within_grant(&grant, dir.path().join("src").to_str().unwrap()).unwrap();
796        assert!(out.iter().any(|p| Path::new(p).ends_with("src/main.rs")));
797    }
798}