1use 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
28pub(crate) fn register_plugin_event(plugin: PluginId, name: &str, doc: &str) -> bool {
36 register_runtime_event(name, doc, format!("plugin:{}", plugin.0))
37}
38
39pub(crate) fn emit_plugin_event(bus: &EventBus, name: String, payload: Vec<u8>) {
45 bus.publish(NativeEvent::Plugin { name, payload });
46}
47
48pub(crate) fn local_utc_offset_seconds() -> i32 {
62 chrono::Local::now().offset().local_minus_utc()
63}
64
65pub(crate) fn clamp_position(buffer: &lattice_core::Buffer, line: u32, byte: u32) -> (u32, u32) {
76 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
83pub(crate) fn new_uuid() -> Result<String, String> {
114 let mut bytes = [0u8; 16];
115 if let Err(error) = getrandom::getrandom(&mut bytes) {
116 tracing::error!(%error, "new-uuid: the OS entropy source is unavailable");
119 return Err(format!("cannot mint an id without entropy: {error}"));
120 }
121 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
137pub(crate) fn grant_permits_walk(grant: &CapabilityGrant, root: &Path) -> bool {
145 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
158pub 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
192pub 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
205pub(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 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 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
235 Err(e) => Err(format!("fs delete failed: '{path}': {e}")),
236 }
237}
238
239pub(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
267pub(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 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 Err(format!("fs read failed: '{path}' does not exist"))
304 }
305 Err(e) => Err(format!("fs read failed: '{path}': {e}")),
306 }
307}
308
309pub(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 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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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}