Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions crates/volt-core/src/grant_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,28 +99,30 @@ mod tests {
use super::*;
use std::env;

use crate::test_support::lock_grant_state;

#[test]
fn test_create_and_resolve_grant() {
let _guard = lock_grant_state();
let dir = env::temp_dir();
let id = create_grant(dir.clone()).unwrap();
assert!(id.starts_with("grant_"));

let resolved = resolve_grant(&id).unwrap();
assert_eq!(resolved, dir);

// Clean up
revoke_grant(&id);
}

#[test]
fn test_resolve_invalid_grant() {
let _guard = lock_grant_state();
let result = resolve_grant("nonexistent_grant_id");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("FS_SCOPE_INVALID"));
}

#[test]
fn test_revoke_grant() {
let _guard = lock_grant_state();
let dir = env::temp_dir();
let id = create_grant(dir).unwrap();
assert!(revoke_grant(&id));
Expand All @@ -130,13 +132,15 @@ mod tests {

#[test]
fn test_create_grant_rejects_nonexistent_path() {
let _guard = lock_grant_state();
let bad_path = PathBuf::from("/definitely/does/not/exist/volt_test_grant");
let result = create_grant(bad_path);
assert!(result.is_err());
}

#[test]
fn test_create_grant_rejects_file_path() {
let _guard = lock_grant_state();
let base = env::temp_dir();
let file_path = base.join("volt_test_grant_file.txt");
std::fs::write(&file_path, b"test").unwrap();
Expand All @@ -149,17 +153,16 @@ mod tests {

#[test]
fn test_grant_ids_are_unique() {
let _guard = lock_grant_state();
let dir = env::temp_dir();
let id1 = create_grant(dir.clone()).unwrap();
let id2 = create_grant(dir).unwrap();
assert_ne!(id1, id2);

revoke_grant(&id1);
revoke_grant(&id2);
}

#[test]
fn test_clear_grants() {
let _guard = lock_grant_state();
let dir = env::temp_dir();
let _id1 = create_grant(dir.clone()).unwrap();
let _id2 = create_grant(dir).unwrap();
Expand Down
18 changes: 18 additions & 0 deletions crates/volt-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod ipc;
pub mod menu;
pub mod notification;
pub mod permissions;
pub mod plugin_grant_registry;
pub mod security;
pub mod shell;
pub mod tray;
Expand Down Expand Up @@ -37,3 +38,20 @@ pub use tray::TrayConfig;
pub use updater::{UpdateConfig, UpdateError, UpdateInfo};
pub use webview::WebViewConfig;
pub use window::{WindowConfig, WindowHandle};

#[cfg(test)]
pub(crate) mod test_support {
use std::sync::{Mutex, MutexGuard};

/// Shared guard for all tests that touch global grant state (`grant_store`,
/// `plugin_grant_registry`). Both modules use process-wide statics, so
/// parallel tests must serialize through this lock.
static GRANT_TEST_GUARD: Mutex<()> = Mutex::new(());

pub fn lock_grant_state() -> MutexGuard<'static, ()> {
let guard = GRANT_TEST_GUARD.lock().expect("grant test guard");
crate::plugin_grant_registry::clear_delegations();
crate::grant_store::clear_grants();
guard
}
}
126 changes: 126 additions & 0 deletions crates/volt-core/src/plugin_grant_registry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;

use thiserror::Error;

use crate::grant_store;

#[derive(Debug, Error, PartialEq, Eq)]
pub enum PluginGrantError {
#[error("PLUGIN_GRANT_INVALID: grant ID does not exist")]
InvalidGrant,
#[error("PLUGIN_GRANT_INVALID: grant is already delegated to this plugin")]
AlreadyDelegated,
}

static PLUGIN_GRANTS: Mutex<Option<HashMap<String, HashSet<String>>>> = Mutex::new(None);

fn with_store<F, R>(f: F) -> R
where
F: FnOnce(&mut HashMap<String, HashSet<String>>) -> R,
{
let mut guard = PLUGIN_GRANTS
.lock()
.unwrap_or_else(|error| error.into_inner());
let store = guard.get_or_insert_with(HashMap::new);
f(store)
}

pub fn delegate_grant(plugin_id: &str, grant_id: &str) -> Result<(), PluginGrantError> {
if grant_store::resolve_grant(grant_id).is_err() {
return Err(PluginGrantError::InvalidGrant);
}

with_store(|store| {
let grants = store.entry(plugin_id.to_string()).or_default();
if !grants.insert(grant_id.to_string()) {
return Err(PluginGrantError::AlreadyDelegated);
}
Ok(())
})
}

pub fn is_delegated(plugin_id: &str, grant_id: &str) -> bool {
with_store(|store| {
store
.get(plugin_id)
.is_some_and(|grants| grants.contains(grant_id))
})
}

pub fn delegated_grants(plugin_id: &str) -> Vec<String> {
with_store(|store| {
let mut grants = store
.get(plugin_id)
.cloned()
.unwrap_or_default()
.into_iter()
.collect::<Vec<_>>();
grants.sort();
grants
})
}

pub fn revoke_grant(plugin_id: &str, grant_id: &str) {
with_store(|store| {
if let Some(grants) = store.get_mut(plugin_id) {
grants.remove(grant_id);
if grants.is_empty() {
store.remove(plugin_id);
}
}
});
}

pub fn revoke_all(plugin_id: &str) {
with_store(|store| {
store.remove(plugin_id);
});
}

pub fn clear_delegations() {
with_store(|store| store.clear());
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;

use super::*;
use crate::test_support::lock_grant_state;

#[test]
fn delegate_grant_requires_existing_grant() {
let _guard = lock_grant_state();
let error = delegate_grant("acme.search", "missing").expect_err("invalid grant");
assert_eq!(error, PluginGrantError::InvalidGrant);
}

#[test]
fn delegate_grant_tracks_plugin_ownership() {
let _guard = lock_grant_state();
let grant_id = grant_store::create_grant(std::env::temp_dir()).expect("grant");
delegate_grant("acme.search", &grant_id).expect("delegate");

assert!(is_delegated("acme.search", &grant_id));
assert_eq!(delegated_grants("acme.search"), vec![grant_id.clone()]);
assert!(!is_delegated("beta.index", &grant_id));
}

#[test]
fn duplicate_delegation_is_rejected() {
let _guard = lock_grant_state();
let temp = std::env::temp_dir().join("volt-plugin-grants");
std::fs::create_dir_all(&temp).expect("temp");
let grant_id = grant_store::create_grant(PathBuf::from(&temp)).expect("grant");

delegate_grant("acme.search", &grant_id).expect("delegate");
let error = delegate_grant("acme.search", &grant_id).expect_err("duplicate");
assert_eq!(error, PluginGrantError::AlreadyDelegated);

revoke_grant("acme.search", &grant_id);
assert!(!is_delegated("acme.search", &grant_id));

let _ = std::fs::remove_dir_all(temp);
}
}
111 changes: 111 additions & 0 deletions crates/volt-plugin-host/src/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ fn build_config(script_name: &str, source: &str) -> PluginConfig {
}
}

fn build_config_with_grants(
script_name: &str,
source: &str,
delegated_grants: Vec<crate::config::DelegatedGrant>,
) -> PluginConfig {
let mut config = build_config(script_name, source);
config.delegated_grants = delegated_grants;
config
}

#[test]
fn activate_registers_runtime_handlers_and_invokes_command() {
let config = build_config(
Expand Down Expand Up @@ -79,3 +89,104 @@ fn activate_registers_runtime_handlers_and_invokes_command() {
assert_eq!(outbound[4].method, "activate");
assert_eq!(outbound[5].method, "plugin:invoke-command");
}

#[test]
fn activate_uses_storage_request_access_and_grant_fs_bridge() {
let config = build_config(
"storage-and-grants",
r#"
import { definePlugin } from 'volt:plugin';
definePlugin({
async activate(context) {
await context.storage.set('token', 'abc');
await context.storage.get('token');
const access = await context.grants.requestAccess({
title: 'Select search directory',
directory: true,
});
const scoped = context.grants.bindFsScope(access.grantId);
await scoped.exists('child.txt');
}
});
"#,
);
configure_mock(
&config,
vec![
IpcMessage::response(
"plugin-request-1",
"plugin:storage:set",
Some(serde_json::Value::Null),
),
IpcMessage::response(
"plugin-request-2",
"plugin:storage:get",
Some(serde_json::json!("abc")),
),
IpcMessage::response(
"plugin-request-3",
"plugin:request-access",
Some(serde_json::json!({
"grantId": "grant-1",
"path": "C:\\data\\search"
})),
),
IpcMessage::response(
"plugin-request-4",
"plugin:grant-fs:exists",
Some(serde_json::json!(true)),
),
],
);

let mut engine = PluginEngine::start_with_mock(&config).expect("engine");
engine
.dispatch_message(IpcMessage::signal("activate-1", "activate"))
.expect("activate");

let outbound = take_outbound();
assert_eq!(outbound[0].method, "ready");
assert_eq!(outbound[1].method, "plugin:storage:set");
assert_eq!(outbound[2].method, "plugin:storage:get");
assert_eq!(outbound[3].method, "plugin:request-access");
assert_eq!(outbound[4].method, "plugin:grant-fs:exists");
assert_eq!(outbound[5].method, "activate");
}

#[test]
fn delegated_grants_can_bind_without_requesting_access_again() {
let config = build_config_with_grants(
"predelegated-grants",
r#"
import { definePlugin } from 'volt:plugin';
definePlugin({
async activate(context) {
const scoped = context.grants.bindFsScope('grant-1');
await scoped.readFile('child.txt');
}
});
"#,
vec![crate::config::DelegatedGrant {
grant_id: "grant-1".into(),
path: "C:\\data\\search".into(),
}],
);
configure_mock(
&config,
vec![IpcMessage::response(
"plugin-request-1",
"plugin:grant-fs:read-file",
Some(serde_json::json!("ok")),
)],
);

let mut engine = PluginEngine::start_with_mock(&config).expect("engine");
engine
.dispatch_message(IpcMessage::signal("activate-1", "activate"))
.expect("activate");

let outbound = take_outbound();
assert_eq!(outbound[0].method, "ready");
assert_eq!(outbound[1].method, "plugin:grant-fs:read-file");
assert_eq!(outbound[2].method, "activate");
}
Loading
Loading