use super::*;
fn all_capabilities() -> crate::plugin::PluginCapabilities {
crate::plugin::PluginCapabilities {
exec: true,
store: true,
metadata: true,
..Default::default()
}
}
fn no_capabilities() -> crate::plugin::PluginCapabilities {
crate::plugin::PluginCapabilities::default()
}
#[test]
fn parse_prompt_message() {
let json = r#"{"type":"prompt","id":"1","message":"Deploy target:","default":"staging"}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Prompt {
id,
message,
default,
validate,
} => {
assert_eq!(id, "1");
assert_eq!(message, "Deploy target:");
assert_eq!(default, Some("staging".to_string()));
assert!(validate.is_none());
}
_ => panic!("expected Prompt"),
}
}
#[test]
fn parse_confirm_message() {
let json = r#"{"type":"confirm","id":"2","message":"Deploy?","default":false}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Confirm {
id,
message,
default,
} => {
assert_eq!(id, "2");
assert_eq!(message, "Deploy?");
assert_eq!(default, Some(false));
}
_ => panic!("expected Confirm"),
}
}
#[test]
fn parse_select_message() {
let json =
r#"{"type":"select","id":"3","message":"Choose:","options":["a","b","c"],"default":1}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Select {
id,
message,
options,
default,
} => {
assert_eq!(id, "3");
assert_eq!(message, "Choose:");
assert_eq!(options, vec!["a", "b", "c"]);
assert_eq!(default, Some(1));
}
_ => panic!("expected Select"),
}
}
#[test]
fn parse_multi_select_message() {
let json =
r#"{"type":"multi_select","id":"4","message":"Pick:","options":["x","y"],"defaults":[0]}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::MultiSelect {
id,
message,
options,
defaults,
} => {
assert_eq!(id, "4");
assert_eq!(message, "Pick:");
assert_eq!(options, vec!["x", "y"]);
assert_eq!(defaults, Some(vec![0]));
}
_ => panic!("expected MultiSelect"),
}
}
#[test]
fn parse_progress_message() {
let json = r#"{"type":"progress","message":"Uploading","current":3,"total":10}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Progress {
message,
current,
total,
done,
} => {
assert_eq!(message, Some("Uploading".to_string()));
assert_eq!(current, Some(3));
assert_eq!(total, Some(10));
assert_eq!(done, None);
}
_ => panic!("expected Progress"),
}
}
#[test]
fn parse_progress_done() {
let json = r#"{"type":"progress","done":true}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Progress { done, .. } => {
assert_eq!(done, Some(true));
}
_ => panic!("expected Progress"),
}
}
#[test]
fn parse_log_message() {
let json = r#"{"type":"log","level":"warn","message":"No config found"}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Log { level, message } => {
assert_eq!(level, "warn");
assert_eq!(message, "No config found");
}
_ => panic!("expected Log"),
}
}
#[test]
fn parse_output_message() {
let json = r#"{"type":"output","text":"Deployed in 4.2s\n"}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Output { text } => {
assert_eq!(text, "Deployed in 4.2s\n");
}
_ => panic!("expected Output"),
}
}
#[test]
fn parse_store_message() {
let json = r#"{"type":"store","key":"last_target","value":"prod"}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Store { key, value } => {
assert_eq!(key, "last_target");
assert_eq!(value, "prod");
}
_ => panic!("expected Store"),
}
}
#[test]
fn parse_load_message() {
let json = r#"{"type":"load","id":"5","key":"last_target"}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Load { id, key } => {
assert_eq!(id, "5");
assert_eq!(key, "last_target");
}
_ => panic!("expected Load"),
}
}
#[test]
fn parse_exec_message() {
let json = r#"{"type":"exec","id":"6","command":"git tag -l","timeout":10}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Exec {
id,
command,
cwd,
timeout,
} => {
assert_eq!(id, "6");
assert_eq!(command, "git tag -l");
assert!(cwd.is_none());
assert_eq!(timeout, Some(10));
}
_ => panic!("expected Exec"),
}
}
#[test]
fn parse_metadata_message() {
let json = r#"{"type":"metadata","id":"7","keys":["git_tags","git_status"]}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Metadata { id, keys } => {
assert_eq!(id, "7");
assert_eq!(keys, vec!["git_tags", "git_status"]);
}
_ => panic!("expected Metadata"),
}
}
#[test]
fn malformed_json_is_rejected() {
let json = r#"this is not json"#;
let result: Result<OutboundMessage, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn unknown_type_is_rejected() {
let json = r#"{"type":"unknown_future_type","id":"99"}"#;
let result: Result<OutboundMessage, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn store_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
handle_store(tmp.path(), "test_key", "test_value").unwrap();
let value = handle_load(tmp.path(), "test_key").unwrap();
assert_eq!(value, serde_json::Value::String("test_value".to_string()));
}
#[test]
fn load_missing_key_returns_null() {
let tmp = tempfile::tempdir().unwrap();
let value = handle_load(tmp.path(), "nonexistent").unwrap();
assert_eq!(value, serde_json::Value::Null);
}
#[test]
fn load_missing_state_file_returns_null() {
let tmp = tempfile::tempdir().unwrap();
let value = handle_load(tmp.path(), "anything").unwrap();
assert_eq!(value, serde_json::Value::Null);
}
#[test]
fn store_overwrites_existing() {
let tmp = tempfile::tempdir().unwrap();
handle_store(tmp.path(), "key", "first").unwrap();
handle_store(tmp.path(), "key", "second").unwrap();
let value = handle_load(tmp.path(), "key").unwrap();
assert_eq!(value, serde_json::Value::String("second".to_string()));
}
#[test]
fn store_multiple_keys() {
let tmp = tempfile::tempdir().unwrap();
handle_store(tmp.path(), "a", "1").unwrap();
handle_store(tmp.path(), "b", "2").unwrap();
assert_eq!(
handle_load(tmp.path(), "a").unwrap(),
serde_json::Value::String("1".to_string())
);
assert_eq!(
handle_load(tmp.path(), "b").unwrap(),
serde_json::Value::String("2".to_string())
);
}
#[test]
fn init_message_serializes() {
let ctx = PluginContext {
msg_type: "init",
protocol: "fledge-v1",
args: vec!["--dry-run".to_string()],
project: Some(ProjectContext {
name: "test".to_string(),
root: "/tmp/test".to_string(),
language: "rust".to_string(),
git: Some(GitContext {
branch: "main".to_string(),
dirty: false,
remote: "origin".to_string(),
remote_url: "https://github.com/test/test".to_string(),
}),
}),
plugin: PluginInfo {
name: "fledge-test".to_string(),
version: "0.1.0".to_string(),
dir: "/tmp/plugins/fledge-test".to_string(),
},
fledge: FledgeInfo {
version: "0.9.1".to_string(),
},
capabilities: CapabilitiesInfo {
exec: true,
store: true,
metadata: false,
filesystem: None,
network: None,
},
};
let json = serde_json::to_string(&ctx).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"], "init");
assert_eq!(parsed["capabilities"]["exec"], true);
assert_eq!(parsed["capabilities"]["store"], true);
assert_eq!(parsed["capabilities"]["metadata"], false);
assert_eq!(parsed["protocol"], "fledge-v1");
assert_eq!(parsed["args"][0], "--dry-run");
assert_eq!(parsed["project"]["name"], "test");
assert_eq!(parsed["project"]["git"]["branch"], "main");
}
#[test]
fn response_serializes_correctly() {
let resp = InboundResponse {
msg_type: "response",
id: "42".to_string(),
value: serde_json::Value::String("hello".to_string()),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"], "response");
assert_eq!(parsed["id"], "42");
assert_eq!(parsed["value"], "hello");
}
#[test]
fn exec_sandbox_blocks_path_escape() {
let tmp = tempfile::tempdir().unwrap();
let result = handle_exec("echo hi", Some("../../.."), None, tmp.path()).unwrap();
let code = result["code"].as_i64().unwrap();
assert_ne!(code, 0);
}
#[test]
fn exec_runs_simple_command() {
let tmp = tempfile::tempdir().unwrap();
let result = handle_exec("echo hello", None, None, tmp.path()).unwrap();
assert_eq!(result["code"].as_i64().unwrap(), 0);
assert!(result["stdout"].as_str().unwrap().contains("hello"));
}
#[test]
fn metadata_handles_unknown_keys() {
let result = handle_metadata(&["nonexistent_key".to_string()]).unwrap();
assert_eq!(result["nonexistent_key"], serde_json::Value::Null);
}
fn compile_test_plugin(src: &str, tmp: &std::path::Path) -> std::path::PathBuf {
let src_path = tmp.join("test_plugin.rs");
std::fs::write(&src_path, src).unwrap();
let bin_name = if cfg!(windows) {
"test_plugin.exe"
} else {
"test_plugin"
};
let bin_path = tmp.join(bin_name);
let output = std::process::Command::new("rustc")
.args([src_path.to_str().unwrap(), "-o", bin_path.to_str().unwrap()])
.output()
.expect("rustc must be available to run plugin tests");
assert!(
output.status.success(),
"rustc failed: {}",
String::from_utf8_lossy(&output.stderr)
);
bin_path
}
#[test]
fn run_protocol_plugin_store_load() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
send("{\"type\":\"log\",\"level\":\"info\",\"message\":\"test started\"}");
send("{\"type\":\"store\",\"key\":\"test_key\",\"value\":\"test_value\"}");
send("{\"type\":\"load\",\"id\":\"load1\",\"key\":\"test_key\"}");
let _resp = lines.next().unwrap().unwrap();
send("{\"type\":\"output\",\"text\":\"done\\n\"}");
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-plugin",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(result.is_ok(), "protocol plugin failed: {:?}", result.err());
let state_path = store_dir.path().join("state.json");
assert!(state_path.exists(), "store should have created state.json");
let state: std::collections::HashMap<String, String> =
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
assert_eq!(
state.get("test_key").map(|s| s.as_str()),
Some("test_value")
);
}
#[test]
fn run_protocol_plugin_exec_and_metadata() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Test exec: run a simple cross-platform command
send("{\"type\":\"exec\",\"id\":\"e1\",\"command\":\"echo hello_from_plugin\"}");
let exec_resp = lines.next().unwrap().unwrap();
assert!(exec_resp.contains("\"id\":\"e1\""), "response should echo id");
assert!(exec_resp.contains("hello_from_plugin"), "exec stdout missing: {}", exec_resp);
// Test metadata
send("{\"type\":\"metadata\",\"id\":\"m1\",\"keys\":[\"env\"]}");
let meta_resp = lines.next().unwrap().unwrap();
assert!(meta_resp.contains("\"id\":\"m1\""), "metadata response should echo id");
assert!(meta_resp.contains("\"env\""), "metadata should contain env key");
send("{\"type\":\"output\",\"text\":\"exec+metadata ok\\n\"}");
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-exec",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(
result.is_ok(),
"exec/metadata test failed: {:?}",
result.err()
);
}
#[test]
fn run_protocol_plugin_graceful_exit_no_messages() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead};
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Exit immediately without sending anything
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-noop",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(
result.is_ok(),
"noop plugin should succeed: {:?}",
result.err()
);
}
#[test]
fn run_protocol_plugin_nonzero_exit_is_error() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead};
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
std::process::exit(42);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-fail",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(result.is_err(), "nonzero exit should be an error");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("42"),
"error should mention exit code: {err_msg}"
);
}
#[test]
fn run_protocol_plugin_malformed_json_skipped() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Send garbage — should be skipped, not crash
send("this is not json at all");
send("{malformed");
send("{\"type\":\"unknown_future_type\",\"id\":\"x\"}");
// Then send valid messages
send("{\"type\":\"store\",\"key\":\"survived\",\"value\":\"yes\"}");
send("{\"type\":\"output\",\"text\":\"still alive\\n\"}");
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-malformed",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(
result.is_ok(),
"malformed JSON should be skipped: {:?}",
result.err()
);
let state: std::collections::HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(store_dir.path().join("state.json")).unwrap(),
)
.unwrap();
assert_eq!(state.get("survived").map(|s| s.as_str()), Some("yes"));
}
#[test]
fn run_protocol_plugin_multiple_store_load_cycles() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Store multiple keys
send("{\"type\":\"store\",\"key\":\"a\",\"value\":\"1\"}");
send("{\"type\":\"store\",\"key\":\"b\",\"value\":\"2\"}");
send("{\"type\":\"store\",\"key\":\"a\",\"value\":\"3\"}");
// Load them back and verify via string matching
send("{\"type\":\"load\",\"id\":\"la\",\"key\":\"a\"}");
let resp_a = lines.next().unwrap().unwrap();
assert!(resp_a.contains("\"id\":\"la\""), "response should echo id la");
assert!(resp_a.contains("\"3\""), "overwritten value should be 3: {}", resp_a);
send("{\"type\":\"load\",\"id\":\"lb\",\"key\":\"b\"}");
let resp_b = lines.next().unwrap().unwrap();
assert!(resp_b.contains("\"2\""), "b should be 2: {}", resp_b);
// Load nonexistent key — should get null
send("{\"type\":\"load\",\"id\":\"lc\",\"key\":\"nonexistent\"}");
let resp_c = lines.next().unwrap().unwrap();
assert!(resp_c.contains("null"), "missing key should return null: {}", resp_c);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-multi-store",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(
result.is_ok(),
"multi store/load failed: {:?}",
result.err()
);
}
#[test]
fn run_protocol_plugin_receives_init_context() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let init_line = lines.next().unwrap().unwrap();
// Verify init message structure via string matching (no serde_json in standalone rustc)
assert!(init_line.contains("\"type\":\"init\""), "missing type:init");
assert!(init_line.contains("\"protocol\":\"fledge-v1\""), "missing protocol");
assert!(init_line.contains("\"plugin\""), "missing plugin field");
assert!(init_line.contains("\"fledge\""), "missing fledge field");
assert!(init_line.contains("\"name\""), "missing plugin name");
assert!(init_line.contains("\"version\""), "missing version");
send("{\"type\":\"log\",\"level\":\"info\",\"message\":\"init validated\"}");
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-init",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(
result.is_ok(),
"init context test failed: {:?}",
result.err()
);
}
#[test]
fn run_protocol_plugin_exec_sandbox_blocks_escape() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Try to escape sandbox with nonexistent path traversal
send("{\"type\":\"exec\",\"id\":\"e1\",\"command\":\"echo pwned\",\"cwd\":\"../../..\"}");
let resp = lines.next().unwrap().unwrap();
assert!(!resp.contains("\"code\":0"), "sandbox escape should be blocked: {}", resp);
assert!(
resp.contains("does not exist") || resp.contains("escapes project"),
"expected sandbox error message, got: {}", resp
);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-sandbox",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(result.is_ok(), "sandbox test failed: {:?}", result.err());
}
#[test]
fn run_protocol_plugin_exec_timeout_returns_code_124() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Request exec with a command that sleeps longer than the timeout
send("{\"type\":\"exec\",\"id\":\"t1\",\"command\":\"sleep 30\",\"timeout\":1}");
let resp = lines.next().unwrap().unwrap();
assert!(resp.contains("\"code\":124"), "expected timeout code 124, got: {}", resp);
assert!(resp.contains("timed out"), "expected timeout message, got: {}", resp);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-timeout",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(result.is_ok(), "timeout test failed: {:?}", result.err());
}
#[test]
fn sanitize_remote_url_strips_credentials() {
assert_eq!(
super::sanitize_remote_url("https://token@github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
assert_eq!(
super::sanitize_remote_url("https://user:pass@github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
assert_eq!(
super::sanitize_remote_url("https://github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
assert_eq!(
super::sanitize_remote_url("git@github.com:org/repo.git"),
"git@github.com:org/repo.git"
);
}
#[test]
fn store_rejects_empty_key() {
let tmp = tempfile::tempdir().unwrap();
let err = handle_store(tmp.path(), "", "value").unwrap_err();
assert!(
err.to_string().contains("must not be empty"),
"expected empty key error, got: {err}"
);
}
#[test]
fn store_rejects_control_characters_in_key() {
let tmp = tempfile::tempdir().unwrap();
let err = handle_store(tmp.path(), "bad\x00key", "value").unwrap_err();
assert!(
err.to_string().contains("control characters"),
"expected control char error, got: {err}"
);
let err2 = handle_store(tmp.path(), "bad\nkey", "value").unwrap_err();
assert!(
err2.to_string().contains("control characters"),
"expected control char error, got: {err2}"
);
}
#[test]
fn store_creates_lock_file() {
let tmp = tempfile::tempdir().unwrap();
handle_store(tmp.path(), "key", "value").unwrap();
assert!(tmp.path().join("state.json.lock").exists());
}
#[test]
fn store_atomic_write() {
let tmp = tempfile::tempdir().unwrap();
handle_store(tmp.path(), "key", "value").unwrap();
assert!(tmp.path().join("state.json").exists());
assert!(!tmp.path().join("state.json.tmp").exists());
}
#[test]
fn capability_blocks_exec() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
send("{\"type\":\"exec\",\"id\":\"e1\",\"command\":\"echo blocked\"}");
let resp = lines.next().unwrap().unwrap();
assert!(resp.contains("\"code\":126"), "exec should be blocked with code 126: {}", resp);
assert!(resp.contains("not granted"), "should mention not granted: {}", resp);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let caps = no_capabilities();
let result =
super::run_protocol_plugin(&bin, &[], "test-no-exec", "0.1.0", store_dir.path(), &caps);
assert!(
result.is_ok(),
"blocked exec should not crash: {:?}",
result.err()
);
}
#[test]
fn capability_blocks_store() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
send("{\"type\":\"store\",\"key\":\"secret\",\"value\":\"data\"}");
send("{\"type\":\"load\",\"id\":\"l1\",\"key\":\"secret\"}");
let resp = lines.next().unwrap().unwrap();
assert!(resp.contains("null"), "load should return null when store blocked: {}", resp);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let caps = no_capabilities();
let result =
super::run_protocol_plugin(&bin, &[], "test-no-store", "0.1.0", store_dir.path(), &caps);
assert!(
result.is_ok(),
"blocked store should not crash: {:?}",
result.err()
);
assert!(
!store_dir.path().join("state.json").exists(),
"state.json should not be created when store is blocked"
);
}
#[test]
fn capability_blocks_metadata() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
send("{\"type\":\"metadata\",\"id\":\"m1\",\"keys\":[\"env\"]}");
let resp = lines.next().unwrap().unwrap();
assert!(resp.contains("\"id\":\"m1\""), "should echo id: {}", resp);
// Should get empty object, not env data
assert!(!resp.contains("PATH"), "should not contain env data when blocked: {}", resp);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let caps = no_capabilities();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-no-metadata",
"0.1.0",
store_dir.path(),
&caps,
);
assert!(
result.is_ok(),
"blocked metadata should not crash: {:?}",
result.err()
);
}
#[test]
fn init_message_includes_capabilities() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let init_line = lines.next().unwrap().unwrap();
assert!(init_line.contains("\"capabilities\""), "init should contain capabilities: {}", init_line);
assert!(init_line.contains("\"exec\":true"), "should have exec:true: {}", init_line);
assert!(init_line.contains("\"store\":false"), "should have store:false: {}", init_line);
assert!(init_line.contains("\"metadata\":true"), "should have metadata:true: {}", init_line);
send("{\"type\":\"log\",\"level\":\"info\",\"message\":\"caps validated\"}");
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let caps = crate::plugin::PluginCapabilities {
exec: true,
store: false,
metadata: true,
..Default::default()
};
let result = super::run_protocol_plugin(
&bin,
&[],
"test-caps-init",
"0.1.0",
store_dir.path(),
&caps,
);
assert!(result.is_ok(), "caps init test failed: {:?}", result.err());
}
#[test]
fn capabilities_parse_from_toml() {
let caps_str = r#"
exec = true
store = true
metadata = false
"#;
let caps: crate::plugin::PluginCapabilities = toml::from_str(caps_str).unwrap();
assert!(caps.exec);
assert!(caps.store);
assert!(!caps.metadata);
}
#[test]
fn capabilities_default_to_false() {
let caps: crate::plugin::PluginCapabilities = toml::from_str("").unwrap();
assert!(!caps.exec);
assert!(!caps.store);
assert!(!caps.metadata);
}
#[test]
fn exec_output_cap_is_10mb() {
assert_eq!(MAX_EXEC_OUTPUT_SIZE, 10 * 1024 * 1024);
}
#[test]
fn bounded_read_truncates_at_limit() {
use std::io::Cursor;
let data = vec![0xABu8; MAX_EXEC_OUTPUT_SIZE + 1024];
let reader = Cursor::new(data);
let mut buf = Vec::new();
reader
.take(MAX_EXEC_OUTPUT_SIZE as u64)
.read_to_end(&mut buf)
.unwrap();
assert_eq!(buf.len(), MAX_EXEC_OUTPUT_SIZE);
}