Compare commits
2
Commits
dc81f78c20
...
c54edea596
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54edea596 | ||
|
|
9ef73e6bc7 |
+7
-2
@@ -516,8 +516,13 @@ builds `/plugins/notes/source/`:
|
||||
its caller ends (under the normal process stopping deadline).
|
||||
- A call to `cargo.build` gets a process ID, a folder in `/proc`, and journal
|
||||
entries, like other calls.
|
||||
- `/plugins/cargo/sdk/` shows the source of the SDK. The dependency
|
||||
`sausage-plugin = "0.1"` points to the same files in the host's cache.
|
||||
- `/plugins/cargo/sdk/` is the SDK used by builds. The dependency
|
||||
`sausage-plugin = "0.1"` points directly to this folder, including its macros
|
||||
and interface crate. Edits apply to the next build without a host restart.
|
||||
- The host seeds a missing SDK folder from its embedded copy at startup or
|
||||
before a build. An existing folder is never overwritten or repaired, even
|
||||
after a host upgrade. To reset to the host's bundled SDK, remove the whole
|
||||
folder; the next startup or build re-seeds it. Back up local edits first.
|
||||
- Compiled crates stay in the host's cache, outside the world, and all worlds
|
||||
share them. So a second build compiles only what changed.
|
||||
- Build scripts can compile C: `CC_wasm32_wasip2` and `AR_wasm32_wasip2`
|
||||
|
||||
@@ -97,6 +97,12 @@ config/boot.yaml the services that start with the world
|
||||
config/agent.yaml the configuration of the agent
|
||||
```
|
||||
|
||||
Rust builds use the SDK in `plugins/cargo/sdk/` directly. The host seeds this
|
||||
folder only when it is missing; restarts and host upgrades preserve existing
|
||||
files. SDK edits take effect on the next `cargo.build`, without a restart.
|
||||
To reset to the host's bundled SDK, back up any edits and remove the whole
|
||||
folder; the next startup or build re-seeds it.
|
||||
|
||||
Each process also sees `/proc` (the processes and the state of the core),
|
||||
`/self` (its own folder in `/proc`), and `/run` (shared files in memory).
|
||||
|
||||
|
||||
+33
-43
@@ -3,7 +3,6 @@ use crate::{Host, world::World};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
@@ -169,10 +168,9 @@ pub struct Cargo {
|
||||
runner: PathBuf,
|
||||
target: PathBuf,
|
||||
sdk: PathBuf,
|
||||
sdk_files: Arc<std::collections::BTreeMap<PathBuf, Vec<u8>>>,
|
||||
}
|
||||
impl Cargo {
|
||||
/// Installs changed SDK files and selects the current sausage executable as runner.
|
||||
/// Seeds a missing world SDK and selects the current sausage executable as runner.
|
||||
pub fn new(world: World, cache: &Path) -> Result<Self> {
|
||||
let mut runner = std::env::current_exe()?;
|
||||
if runner
|
||||
@@ -183,9 +181,23 @@ impl Cargo {
|
||||
}
|
||||
Self::with_runner(world, runner, cache)
|
||||
}
|
||||
/// Installs the SDK and accepts an explicit sausage binary, including for init integrations.
|
||||
/// Seeds a missing world SDK and accepts an explicit sausage binary, including for init integrations.
|
||||
pub fn with_runner(world: World, runner: PathBuf, cache: &Path) -> Result<Self> {
|
||||
let sdk = world.plugin_dir(ID).join("sdk");
|
||||
Self::seed_sdk(&sdk)?;
|
||||
Ok(Self {
|
||||
sdk,
|
||||
target: std::path::absolute(cache)?.join("cargo-target"),
|
||||
world,
|
||||
runner,
|
||||
builds: Arc::new(tokio::sync::Mutex::new(())),
|
||||
})
|
||||
}
|
||||
/// Seeds only an absent SDK; an existing world owns all of its SDK files.
|
||||
fn seed_sdk(sdk: &Path) -> Result<()> {
|
||||
if sdk.try_exists()? {
|
||||
return Ok(());
|
||||
}
|
||||
let mut files = std::collections::BTreeMap::new();
|
||||
collect_files(&SDK, Path::new("src"), &mut files);
|
||||
collect_files(&MACROS, Path::new("macros/src"), &mut files);
|
||||
@@ -223,26 +235,23 @@ impl Cargo {
|
||||
}
|
||||
files.insert(destination, document.to_string().into_bytes());
|
||||
}
|
||||
let mut hash = Sha256::new();
|
||||
let parent = sdk.parent().context("SDK has no parent")?;
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix("seed-sdk-")
|
||||
.tempdir_in(parent)
|
||||
.with_context(|| format!("stage SDK in {}", parent.display()))?;
|
||||
for (path, bytes) in &files {
|
||||
let name = path.to_str().unwrap().as_bytes();
|
||||
hash.update((name.len() as u64).to_le_bytes());
|
||||
hash.update(name);
|
||||
hash.update((bytes.len() as u64).to_le_bytes());
|
||||
hash.update(bytes);
|
||||
write_changed(&sdk.join(path), bytes)?;
|
||||
write_changed(&staging.path().join(path), bytes)?;
|
||||
}
|
||||
let cached = std::path::absolute(cache)?
|
||||
.join("sdk")
|
||||
.join(format!("{:x}", hash.finalize()));
|
||||
Ok(Self {
|
||||
sdk: cached,
|
||||
sdk_files: Arc::new(files),
|
||||
target: std::path::absolute(cache)?.join("cargo-target"),
|
||||
world,
|
||||
runner,
|
||||
builds: Arc::new(tokio::sync::Mutex::new(())),
|
||||
})
|
||||
// Another builder may have seeded the world while we prepared the files.
|
||||
if !sdk.try_exists()?
|
||||
&& let Err(error) = std::fs::rename(staging.path(), sdk)
|
||||
&& !sdk.try_exists()?
|
||||
{
|
||||
return Err(error).with_context(|| format!("seed {}", sdk.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Registers cargo.build as a cancellable native process.
|
||||
pub fn register(self, host: &Host) {
|
||||
@@ -310,27 +319,8 @@ impl Cargo {
|
||||
let source = source.to_path_buf();
|
||||
let copy = work.clone();
|
||||
let sdk = self.sdk.clone();
|
||||
let sdk_files = self.sdk_files.clone();
|
||||
let build_guard = tokio::task::spawn_blocking(move || -> Result<_> {
|
||||
// Host startup installs only the readable world copy. Builds populate the cache.
|
||||
if !sdk.is_dir() {
|
||||
let parent = sdk.parent().unwrap();
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create {}", parent.display()))?;
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix("install-")
|
||||
.tempdir_in(parent)
|
||||
.with_context(|| format!("stage SDK in {}", parent.display()))?;
|
||||
for (path, bytes) in sdk_files.iter() {
|
||||
write_changed(&staging.path().join(path), bytes)?;
|
||||
}
|
||||
// Another builder can publish the same complete SDK first.
|
||||
if let Err(error) = std::fs::rename(staging.path(), &sdk)
|
||||
&& !sdk.is_dir()
|
||||
{
|
||||
return Err(error).with_context(|| format!("install {}", sdk.display()));
|
||||
}
|
||||
}
|
||||
Self::seed_sdk(&sdk)?;
|
||||
if cleanup.exists() {
|
||||
std::fs::remove_dir_all(&cleanup)
|
||||
.with_context(|| format!("remove {}", cleanup.display()))?;
|
||||
@@ -659,7 +649,7 @@ fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Collects embedded source files for deterministic hashing and installation.
|
||||
/// Collects embedded source files for seeding a missing world SDK.
|
||||
fn collect_files(
|
||||
directory: &Dir<'_>,
|
||||
destination: &Path,
|
||||
|
||||
+19
-2
@@ -7,6 +7,23 @@ use serde_json::{Value, json};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn params_not_object(params: &Value) -> Value {
|
||||
let kind = match params {
|
||||
Value::String(_) => "a string",
|
||||
Value::Array(_) => "an array",
|
||||
Value::Number(_) => "a number",
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "a boolean",
|
||||
Value::Object(_) => "an object",
|
||||
};
|
||||
error(
|
||||
-32602,
|
||||
&format!(
|
||||
"params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string."
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Queue an incoming response or notification for its destination.
|
||||
pub(crate) fn send(&self, pid: &str, value: Value) {
|
||||
@@ -69,7 +86,7 @@ impl Host {
|
||||
let host = self.clone();
|
||||
Box::pin(async move {
|
||||
if !params.is_object() {
|
||||
return Err(error(-32602, "params must be an object"));
|
||||
return Err(params_not_object(¶ms));
|
||||
}
|
||||
if caller != "console" && caller != "core" {
|
||||
let state = host.0.state.lock().unwrap();
|
||||
@@ -238,7 +255,7 @@ impl Host {
|
||||
// Deliver progress before a following response removes its destination.
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
let result = if !params.is_object() {
|
||||
Err(error(-32602, "params must be an object"))
|
||||
Err(params_not_object(¶ms))
|
||||
} else if method == "core.unlock" {
|
||||
if let Some(path) = params["path"].as_str() {
|
||||
self.unlock(pid, path);
|
||||
|
||||
+92
-9
@@ -278,14 +278,7 @@ async fn manifest_and_nested_sources() {
|
||||
assert!(manifest.contains("optional = true"));
|
||||
let document: toml_edit::DocumentMut = manifest.parse().unwrap();
|
||||
let sdk = Path::new(document["target"]["cfg(target_arch = \"wasm32\")"]["dependencies"]["sausage_plugin"]["path"].as_str().unwrap());
|
||||
assert_eq!(sdk.parent().unwrap(), Path::new(CACHE).join("sdk"));
|
||||
let hash = sdk.file_name().unwrap().to_str().unwrap();
|
||||
assert_eq!(hash.len(), 64);
|
||||
assert!(hash.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
||||
assert_eq!(
|
||||
std::fs::read(sdk.join("src/lib.rs")).unwrap(),
|
||||
std::fs::read(valid.world.0.join("plugins/cargo/sdk/src/lib.rs")).unwrap()
|
||||
);
|
||||
assert_eq!(sdk, valid.world.0.join("plugins/cargo/sdk"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("src/target/data")).unwrap(),
|
||||
"nested target"
|
||||
@@ -483,7 +476,7 @@ async fn source_copy() {
|
||||
assert!(
|
||||
std::fs::read_to_string(valid.world.0.join("state/cargo/work/Cargo.toml"))
|
||||
.unwrap()
|
||||
.contains(Path::new(CACHE).join("sdk").to_str().unwrap())
|
||||
.contains(valid.world.0.join("plugins/cargo/sdk").to_str().unwrap())
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: compiler failures return compiler diagnostics.
|
||||
@@ -637,6 +630,96 @@ async fn unchanged_sdk() {
|
||||
modified
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: an SDK edit is used by the next build on the same running host.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn world_sdk_edits_apply_without_restart() {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(180), async {
|
||||
let world = TestWorld::new();
|
||||
world.simple("sdk-probe", "fn main() {}\n#[test]\nfn world_sdk_probe() { assert_eq!(sausage_plugin::world_sdk_probe(), 1); }\n");
|
||||
let manifest = world.0.join("plugins/sdk-probe/source/Cargo.toml");
|
||||
let mut text = std::fs::read_to_string(&manifest).unwrap();
|
||||
text.push_str("\n[dependencies]\nsausage-plugin = '0.1'\n");
|
||||
std::fs::write(manifest, text).unwrap();
|
||||
let host = Host::start_with_cache(&world.0, Path::new(CACHE)).await.unwrap();
|
||||
let library = world.0.join("plugins/cargo/sdk/src/lib.rs");
|
||||
let original = std::fs::read_to_string(&library).unwrap();
|
||||
std::fs::write(&library, format!("{original}\npub fn world_sdk_probe() -> u8 {{ 1 }}\n")).unwrap();
|
||||
let params = json!({"plugin":"sdk-probe", "timeout_secs":120, "test_timeout_secs":10});
|
||||
let first = host.call("cargo.build", params.clone()).await.unwrap();
|
||||
assert_eq!(first["tests"]["passed"], 1);
|
||||
// Only the SDK changes: Cargo must not reuse its old compiled version.
|
||||
std::fs::write(&library, format!("{original}\npub fn world_sdk_probe() -> u8 {{ 2 }}\n")).unwrap();
|
||||
let second = host.call("cargo.build", params).await.unwrap_err();
|
||||
assert_eq!(second["code"], -32004);
|
||||
assert_eq!(second["data"]["tests"]["failed"], 1);
|
||||
assert!(second["data"]["tests"]["output"].as_str().unwrap().contains("world_sdk_probe"));
|
||||
host.stop().await;
|
||||
}).await.expect("SDK edit regression timed out");
|
||||
}
|
||||
/// PROTOCOL 11: startup seeds a missing SDK, but never repairs or overwrites an existing one.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn world_sdk_survives_restart_and_missing_sdk_is_reseeded() {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||
let world = TestWorld::new();
|
||||
let sdk = world.0.join("plugins/cargo/sdk");
|
||||
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
let library = sdk.join("src/lib.rs");
|
||||
let original = std::fs::read(&library).unwrap();
|
||||
let manifest = sdk.join("Cargo.toml");
|
||||
let edited = b"// world SDK edit must survive restart\n";
|
||||
std::fs::write(&library, edited).unwrap();
|
||||
std::fs::write(&manifest, "# edited manifest\n").unwrap();
|
||||
let removed = sdk.join("macros/src/lib.rs");
|
||||
std::fs::remove_file(&removed).unwrap();
|
||||
host.stop().await;
|
||||
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(&library).unwrap(), edited);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&manifest).unwrap(),
|
||||
"# edited manifest\n"
|
||||
);
|
||||
assert!(!removed.exists(), "existing SDK must not be repaired");
|
||||
host.stop().await;
|
||||
std::fs::remove_dir_all(&sdk).unwrap();
|
||||
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(&library).unwrap(), original);
|
||||
assert!(removed.is_file());
|
||||
assert!(sdk.join("interface/src/lib.rs").is_file());
|
||||
host.stop().await;
|
||||
})
|
||||
.await
|
||||
.expect("SDK restart regression timed out");
|
||||
}
|
||||
/// PROTOCOL 11: removing the SDK while the host is running re-seeds it on the next build.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn world_sdk_missing_at_build_is_reseeded() {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(180), async {
|
||||
let world = TestWorld::new();
|
||||
world.simple("reseed-probe", "fn main() {}\n");
|
||||
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
let sdk = world.0.join("plugins/cargo/sdk");
|
||||
let original = std::fs::read(sdk.join("src/lib.rs")).unwrap();
|
||||
std::fs::remove_dir_all(&sdk).unwrap();
|
||||
host.call(
|
||||
"cargo.build",
|
||||
json!({"plugin":"reseed-probe", "timeout_secs":120, "test_timeout_secs":10}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(sdk.join("src/lib.rs")).unwrap(), original);
|
||||
host.stop().await;
|
||||
})
|
||||
.await
|
||||
.expect("SDK re-seed build regression timed out");
|
||||
}
|
||||
/// PROTOCOL 11: init can build a plugin without a running host.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn standalone_builder() {
|
||||
|
||||
+107
-2
@@ -500,14 +500,119 @@ async fn unimplemented_core_methods_return_method_not_found() {
|
||||
async fn params_must_be_objects() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
for (params, kind) in [
|
||||
(json!("{\"text\":\"hello\"}"), "a string"),
|
||||
(json!([]), "an array"),
|
||||
(json!(42), "a number"),
|
||||
(json!(null), "null"),
|
||||
(json!(true), "a boolean"),
|
||||
] {
|
||||
let expected = json!({
|
||||
"code": -32602,
|
||||
"message": format!("params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string.")
|
||||
});
|
||||
for method in ["probe.echo", "core.ps"] {
|
||||
assert_eq!(h.call(method, params.clone()).await.unwrap_err(), expected);
|
||||
}
|
||||
assert_eq!(
|
||||
h.call("core.call", json!({"method":"probe.echo", "params":params}))
|
||||
.await
|
||||
.unwrap_err(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let object = json!({"text":"hello"});
|
||||
assert_eq!(call(&h, "probe.echo", object.clone()).await, object);
|
||||
assert_eq!(
|
||||
h.call("probe.echo", json!([])).await.unwrap_err()["code"],
|
||||
-32602
|
||||
call(
|
||||
&h,
|
||||
"core.call",
|
||||
json!({"method":"probe.echo", "params":object})
|
||||
)
|
||||
.await,
|
||||
object
|
||||
);
|
||||
assert_eq!(
|
||||
call(&h, "core.call", json!({"method":"probe.echo"})).await,
|
||||
json!({})
|
||||
);
|
||||
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.1: stdout fast paths reject non-objects without changing defaults.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stdout_params_must_be_objects() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let mut notifications = h.subscribe();
|
||||
for method in ["core.notify", "core.unlock", "probe.echo"] {
|
||||
for (params, kind) in [
|
||||
(json!("{}"), "a string"),
|
||||
(json!([]), "an array"),
|
||||
(json!(1.5), "a number"),
|
||||
(json!(null), "null"),
|
||||
(json!(false), "a boolean"),
|
||||
] {
|
||||
let response = call(
|
||||
&h,
|
||||
"probe.raw_call",
|
||||
json!({
|
||||
"jsonrpc":"2.0", "id":1, "method":method, "params":params
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["id"], 1);
|
||||
assert_eq!(
|
||||
response["error"],
|
||||
json!({
|
||||
"code": -32602,
|
||||
"message": format!("params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string.")
|
||||
})
|
||||
);
|
||||
}
|
||||
let params = match method {
|
||||
"core.notify" => json!({"name":"valid", "params":{"n":1}}),
|
||||
"core.unlock" => json!({"path":"/state/not-held"}),
|
||||
_ => json!({"n":1}),
|
||||
};
|
||||
let response = call(
|
||||
&h,
|
||||
"probe.raw_call",
|
||||
json!({
|
||||
"jsonrpc":"2.0", "id":1, "method":method, "params":params
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["result"],
|
||||
if method == "probe.echo" {
|
||||
params
|
||||
} else {
|
||||
Value::Null
|
||||
}
|
||||
);
|
||||
assert!(response.get("error").is_none());
|
||||
let omitted = call(
|
||||
&h,
|
||||
"probe.raw_call",
|
||||
json!({
|
||||
"jsonrpc":"2.0", "id":1, "method":method
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
match method {
|
||||
"core.notify" => {
|
||||
assert_eq!(omitted["error"]["message"], "name must be a string");
|
||||
assert_eq!(notifications.try_recv().unwrap()["params"]["name"], "valid");
|
||||
}
|
||||
"core.unlock" => assert_eq!(omitted["error"]["message"], "path must be a string"),
|
||||
_ => assert_eq!(omitted["result"], json!({})),
|
||||
}
|
||||
}
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.9: exit without answer reports status and last logs.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exit_without_answer_reports_status_and_last_logs() {
|
||||
|
||||
@@ -470,6 +470,10 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
|
||||
"network" => network(params),
|
||||
"read" => read_file(params),
|
||||
"write" => write_file(params),
|
||||
"raw_call" => {
|
||||
send(params.clone());
|
||||
receive(lines)
|
||||
}
|
||||
"call" => outgoing_call(params, lines),
|
||||
"lock" => lock(params, lines),
|
||||
_ => {
|
||||
|
||||
@@ -270,7 +270,7 @@ pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
|
||||
fn methods_document()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace ::sausage_plugin::finish_document(doc)}
|
||||
fn describe()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace #(doc.endpoint::<#types>(#names);)* ::sausage_plugin::finish_document(doc)}
|
||||
fn discover(&self)->::sausage_plugin::serde_json::Value {<Self as ::sausage_plugin::Interface>::describe()}
|
||||
fn dispatch<'a>(&'a self, method:&'a str, params: ::sausage_plugin::serde_json::Value,ctx:&'a ::sausage_plugin::Ctx)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=::sausage_plugin::Result<::sausage_plugin::serde_json::Value>>+'a>> {Box::pin(async move {if !params.is_object() {return Err(::sausage_plugin::Error::new(-32602,"params must be an object"));} match method {#(#arms,)* _=>Err(::sausage_plugin::Error::new(-32601,"method not found"))}})}
|
||||
fn dispatch<'a>(&'a self, method:&'a str, params: ::sausage_plugin::serde_json::Value,ctx:&'a ::sausage_plugin::Ctx)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=::sausage_plugin::Result<::sausage_plugin::serde_json::Value>>+'a>> {Box::pin(async move {if !params.is_object() {return Err(::sausage_plugin::Error::params_not_object(¶ms));} match method {#(#arms,)* _=>Err(::sausage_plugin::Error::new(-32601,"method not found"))}})}
|
||||
#stop
|
||||
}})
|
||||
})();
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ impl IntoFuture for Call {
|
||||
fn into_future(self) -> Self::IntoFuture {
|
||||
Box::pin(async move {
|
||||
if !self.params.is_object() {
|
||||
return Err(Error::new(-32602, "params must be an object"));
|
||||
return Err(Error::params_not_object(&self.params));
|
||||
}
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let id = STATE.with(|s| {
|
||||
|
||||
@@ -15,6 +15,25 @@ pub struct Error {
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
impl Error {
|
||||
/// Build the object-params diagnostic used by calls and generated dispatch.
|
||||
#[doc(hidden)]
|
||||
pub fn params_not_object(params: &Value) -> Self {
|
||||
let kind = match params {
|
||||
Value::String(_) => "a string",
|
||||
Value::Array(_) => "an array",
|
||||
Value::Number(_) => "a number",
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "a boolean",
|
||||
Value::Object(_) => "an object",
|
||||
};
|
||||
Self::new(
|
||||
-32602,
|
||||
format!(
|
||||
"params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string."
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Construct an error without structured details.
|
||||
pub fn new(code: i64, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -116,6 +116,53 @@ fn discovery_and_dispatch() {
|
||||
.into();
|
||||
assert_eq!(internal.code, -32603);
|
||||
}
|
||||
#[sausage_plugin::test]
|
||||
async fn params_must_be_objects() {
|
||||
for (params, kind) in [
|
||||
(json!("{\"a\":2,\"b\":3}"), "a string"),
|
||||
(json!([]), "an array"),
|
||||
(json!(42), "a number"),
|
||||
(json!(null), "null"),
|
||||
(json!(true), "a boolean"),
|
||||
] {
|
||||
let message = format!(
|
||||
"params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string."
|
||||
);
|
||||
let outgoing = sausage_plugin::call("plugin.add", params.clone())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(outgoing.code, -32602);
|
||||
assert_eq!(outgoing.message, message);
|
||||
for method in ["add", "choice", "unknown"] {
|
||||
let incoming = Plugin
|
||||
.dispatch(method, params.clone(), &Ctx::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(incoming.code, -32602);
|
||||
assert_eq!(incoming.message, message);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
Plugin
|
||||
.dispatch("add", json!({"a":2,"b":3}), &Ctx::default())
|
||||
.await
|
||||
.unwrap(),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
Plugin
|
||||
.dispatch("defaults", json!({}), &Ctx::default())
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"value":""})
|
||||
);
|
||||
// An object reaches transport validation rather than the params rejection.
|
||||
let outgoing = sausage_plugin::call("plugin.add", json!({"a":2,"b":3}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(outgoing.message.contains("no core transport"));
|
||||
}
|
||||
|
||||
#[sausage_plugin::test]
|
||||
async fn missing_core_fails_without_wait() {
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user