feat: add authorized Plugin Ticket data capabilities
This commit is contained in:
@@ -360,6 +360,7 @@ impl ArtifactCache {
|
||||
|| !same_json(&d.tools, &m.tools)?
|
||||
|| !same_json(&d.connections, &m.connections)?
|
||||
|| !same_json(&d.requested_grants, &m.requested_grants)?
|
||||
|| !same_json(&d.data_collections, &m.data_collections)?
|
||||
|| d.config_schema.dcdl != m.config_schema
|
||||
|| d.config_schema.digest != schema.digest
|
||||
|| d.config_schema.identity != schema.identity
|
||||
|
||||
@@ -46,6 +46,7 @@ fn fixture() -> (CanonicalPluginArtifact, PluginArtifact) {
|
||||
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
|
||||
connections: vec![],
|
||||
requested_grants: vec![],
|
||||
data_collections: vec![],
|
||||
config_schema: server_api::PluginSchemaContribution {
|
||||
identity: format!("{}/config", m.id),
|
||||
version: m.version.clone(),
|
||||
@@ -391,6 +392,7 @@ async fn canonical_package_must_match_complete_descriptor_not_only_digests() {
|
||||
"tools",
|
||||
"connections",
|
||||
"grants",
|
||||
"data_collections",
|
||||
"schema_identity",
|
||||
"schema_version",
|
||||
"schema_digest",
|
||||
@@ -415,6 +417,14 @@ async fn canonical_package_must_match_complete_descriptor_not_only_digests() {
|
||||
"grants" => d
|
||||
.requested_grants
|
||||
.push(server_api::PluginGrantMetadata::Logging),
|
||||
"data_collections" => d
|
||||
.data_collections
|
||||
.push(server_api::PluginDataSchemaMetadata {
|
||||
collection: "notes".into(),
|
||||
schema_id: "note".into(),
|
||||
schema_version: "1".into(),
|
||||
schema: serde_json::json!({"type":"string"}),
|
||||
}),
|
||||
"schema_identity" => d.config_schema.identity.push('x'),
|
||||
"schema_version" => d.config_schema.version.push('x'),
|
||||
"schema_digest" => d.config_schema.digest.push('x'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod ticket;
|
||||
use crate::sandbox::SandboxLimits;
|
||||
use crate::{
|
||||
bindings,
|
||||
@@ -297,6 +298,15 @@ impl Executor {
|
||||
|s| s,
|
||||
)
|
||||
.map_err(trap)?;
|
||||
if compiled.manifest.runtime.world == plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD {
|
||||
use crate::host::{extension_data, objects, ticket_read};
|
||||
objects::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
|
||||
.map_err(trap)?;
|
||||
ticket_read::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
|
||||
.map_err(trap)?;
|
||||
extension_data::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
|
||||
.map_err(trap)?;
|
||||
}
|
||||
let instance = bindings::HttpsLoggedToolPlugin::instantiate_async(
|
||||
&mut store,
|
||||
&compiled.component,
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
use super::*;
|
||||
use crate::host::{
|
||||
Ticket, TicketDataOperation as Op, TicketDataResult as Reply, extension_data as data, objects,
|
||||
ticket_read,
|
||||
};
|
||||
use server_api::{
|
||||
PluginDataAddress as Address, PluginDataCell as Cell, PluginDataCollection as Collection,
|
||||
PluginDataExpectation as Expectation, PluginDataMutation as Mutation,
|
||||
};
|
||||
|
||||
fn denied() -> errors::HostError {
|
||||
host::error(errors::Code::Denied, "Ticket capability not granted")
|
||||
}
|
||||
fn invalid() -> errors::HostError {
|
||||
host::error(errors::Code::InvalidInput, "invalid Ticket data request")
|
||||
}
|
||||
fn protocol() -> errors::HostError {
|
||||
host::error(errors::Code::Unavailable, "invalid Ticket owner response")
|
||||
}
|
||||
fn collection(c: data::Collection) -> Collection {
|
||||
Collection {
|
||||
collection: c.collection,
|
||||
schema_id: c.schema_id,
|
||||
schema_version: c.schema_version,
|
||||
}
|
||||
}
|
||||
fn address(a: data::Address) -> Address {
|
||||
Address {
|
||||
collection: collection(a.namespace),
|
||||
key: a.key,
|
||||
}
|
||||
}
|
||||
fn cell(c: Cell) -> data::Cell {
|
||||
match c {
|
||||
Cell::NeverWritten => data::Cell::NeverWritten,
|
||||
Cell::Deleted { last_delete } => data::Cell::Deleted(last_delete),
|
||||
Cell::Present {
|
||||
value_json,
|
||||
last_write,
|
||||
} => data::Cell::Present(data::PresentCell {
|
||||
value_json,
|
||||
last_write,
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn receipt(r: server_api::PluginDataReceipt) -> data::Receipt {
|
||||
data::Receipt {
|
||||
mutation_id: r.mutation_id,
|
||||
result_digest: r.result_digest,
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
fn ticket_world(&self) -> Result<(), errors::HostError> {
|
||||
if self.manifest.runtime.world != plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD {
|
||||
return Err(denied());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn data_schema(&self, c: &Collection) -> Result<&serde_json::Value, errors::HostError> {
|
||||
self.manifest
|
||||
.data_collections
|
||||
.iter()
|
||||
.find(|d| {
|
||||
d.collection == c.collection
|
||||
&& d.schema_id == c.schema_id
|
||||
&& d.schema_version == c.schema_version
|
||||
})
|
||||
.map(|d| &d.schema)
|
||||
.ok_or_else(denied)
|
||||
}
|
||||
fn data_grant(&self, ctx: &InvocationContext, c: &Collection, write: bool) -> bool {
|
||||
let id = (
|
||||
c.collection.clone(),
|
||||
c.schema_id.clone(),
|
||||
c.schema_version.clone(),
|
||||
);
|
||||
let requested = if write {
|
||||
plugin::PluginRequestedGrant::TicketDataWrite {
|
||||
collection: c.collection.clone(),
|
||||
schema_id: c.schema_id.clone(),
|
||||
schema_version: c.schema_version.clone(),
|
||||
}
|
||||
} else {
|
||||
plugin::PluginRequestedGrant::TicketDataRead {
|
||||
collection: c.collection.clone(),
|
||||
schema_id: c.schema_id.clone(),
|
||||
schema_version: c.schema_version.clone(),
|
||||
}
|
||||
};
|
||||
self.manifest.requested_grants.contains(&requested)
|
||||
&& (if write {
|
||||
&ctx.grants.ticket_data_write
|
||||
} else {
|
||||
&ctx.grants.ticket_data_read
|
||||
})
|
||||
.contains(&id)
|
||||
}
|
||||
fn check_cell(&self, namespace: &Collection, c: &Cell) -> Result<(), errors::HostError> {
|
||||
if let Cell::Present { value_json, .. } = c {
|
||||
if value_json.len() > server_api::PLUGIN_DATA_VALUE_LIMIT {
|
||||
return Err(protocol());
|
||||
}
|
||||
let value = serde_json::from_str(value_json).map_err(|_| protocol())?;
|
||||
plugin::schema::validate_data_value(self.data_schema(namespace)?, &value)
|
||||
.map_err(|_| protocol())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn data_call(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
operation: Op,
|
||||
) -> wasmtime::Result<Result<Reply, errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
let checked = (|| {
|
||||
self.operation()?;
|
||||
self.ticket_world()?;
|
||||
crate::owner::validate_operation(&operation).map_err(|_| invalid())?;
|
||||
let (namespace, write, lookup) = match &operation {
|
||||
Op::Get(a) => (&a.collection, false, false),
|
||||
Op::Mutate { address, .. } => (&address.collection, true, false),
|
||||
Op::List { collection, .. } => (collection, false, false),
|
||||
Op::LookupMutation { address, .. } => (&address.collection, false, true),
|
||||
};
|
||||
let schema = self.data_schema(namespace)?;
|
||||
if !self.data_grant(&context, namespace, write || lookup) {
|
||||
return Err(denied());
|
||||
}
|
||||
if let Op::Mutate {
|
||||
mutation: Mutation::Put { value_json, .. },
|
||||
..
|
||||
} = &operation
|
||||
{
|
||||
let value = serde_json::from_str(value_json).map_err(|_| invalid())?;
|
||||
plugin::schema::validate_data_value(schema, &value).map_err(|_| invalid())?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = checked {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let binding = self.table.get(&target)?.binding.clone();
|
||||
let namespace = match &operation {
|
||||
Op::Get(a) | Op::Mutate { address: a, .. } | Op::LookupMutation { address: a, .. } => {
|
||||
a.collection.clone()
|
||||
}
|
||||
Op::List { collection, .. } => collection.clone(),
|
||||
};
|
||||
let list_limit = if let Op::List { limit, .. } = &operation {
|
||||
Some(*limit as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mutating = matches!(&operation, Op::Mutate { .. });
|
||||
let expected_reply = match &operation {
|
||||
Op::Get(_) => 0,
|
||||
Op::Mutate { .. } => 1,
|
||||
Op::List { .. } => 2,
|
||||
Op::LookupMutation { .. } => 3,
|
||||
};
|
||||
let reply = match self
|
||||
.provider
|
||||
.ticket_data(&context, binding.as_ref(), operation)
|
||||
.await
|
||||
{
|
||||
Ok(reply) => reply,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket data result"))?;
|
||||
let actual_reply = match &reply {
|
||||
Reply::Cell(_) => 0,
|
||||
Reply::Mutated(_) => 1,
|
||||
Reply::Listed(_) => 2,
|
||||
Reply::MutationLookup(_) => 3,
|
||||
};
|
||||
if actual_reply != expected_reply {
|
||||
let mut error = protocol();
|
||||
if mutating {
|
||||
error.code = errors::Code::Indeterminate;
|
||||
error.effect = errors::EffectObservation::Indeterminate;
|
||||
}
|
||||
return Ok(Err(error));
|
||||
}
|
||||
let valid = match &reply {
|
||||
Reply::Cell(c) => self.check_cell(&namespace, c),
|
||||
Reply::Listed(page) => {
|
||||
if page.entries.len() > list_limit.unwrap_or(0)
|
||||
|| page.entries.windows(2).any(|p| p[0].key >= p[1].key)
|
||||
|| page.entries.iter().any(|e| {
|
||||
e.key.is_empty()
|
||||
|| e.key.len() > 256
|
||||
|| matches!(e.cell, Cell::NeverWritten)
|
||||
})
|
||||
|| page
|
||||
.next_cursor
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_empty() || s.len() > 4096)
|
||||
{
|
||||
Err(protocol())
|
||||
} else {
|
||||
page.entries
|
||||
.iter()
|
||||
.try_for_each(|e| self.check_cell(&namespace, &e.cell))
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
};
|
||||
if let Err(e) = valid {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(Ok(reply))
|
||||
}
|
||||
}
|
||||
impl objects::HostTicket for State {
|
||||
async fn drop(&mut self, target: Resource<Ticket>) -> wasmtime::Result<()> {
|
||||
self.table.delete(target)?;
|
||||
self.resources -= 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl objects::Host for State {
|
||||
async fn resolve_ticket(
|
||||
&mut self,
|
||||
ticket_ref: String,
|
||||
requested: objects::Access,
|
||||
) -> wasmtime::Result<Result<Resource<Ticket>, errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if ticket_ref.is_empty() || ticket_ref.len() > 256 {
|
||||
return Ok(Err(invalid()));
|
||||
}
|
||||
let writable = matches!(requested, objects::Access::ExtensionWrite);
|
||||
let allowed = self.manifest.requested_grants.iter().any(|g| match g {
|
||||
plugin::PluginRequestedGrant::TicketRead => !writable && context.grants.ticket_read,
|
||||
plugin::PluginRequestedGrant::TicketDataRead {
|
||||
collection,
|
||||
schema_id,
|
||||
schema_version,
|
||||
} => {
|
||||
!writable
|
||||
&& context.grants.ticket_data_read.contains(&(
|
||||
collection.clone(),
|
||||
schema_id.clone(),
|
||||
schema_version.clone(),
|
||||
))
|
||||
}
|
||||
plugin::PluginRequestedGrant::TicketDataWrite {
|
||||
collection,
|
||||
schema_id,
|
||||
schema_version,
|
||||
} => {
|
||||
writable
|
||||
&& context.grants.ticket_data_write.contains(&(
|
||||
collection.clone(),
|
||||
schema_id.clone(),
|
||||
schema_version.clone(),
|
||||
))
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if !allowed {
|
||||
return Ok(Err(denied()));
|
||||
}
|
||||
if self.resources >= self.limits.resources {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::QuotaExceeded,
|
||||
"resource limit",
|
||||
)));
|
||||
}
|
||||
let binding = match self
|
||||
.provider
|
||||
.resolve_ticket(&context, &ticket_ref, requested)
|
||||
.await
|
||||
{
|
||||
Ok(binding) => binding,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket resource"))?;
|
||||
let target = self.table.push(Ticket { binding })?;
|
||||
self.resources += 1;
|
||||
Ok(Ok(target))
|
||||
}
|
||||
}
|
||||
impl ticket_read::Host for State {
|
||||
async fn read(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
) -> wasmtime::Result<Result<ticket_read::Snapshot, errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if !context.grants.ticket_read
|
||||
|| !self
|
||||
.manifest
|
||||
.requested_grants
|
||||
.contains(&plugin::PluginRequestedGrant::TicketRead)
|
||||
{
|
||||
return Ok(Err(denied()));
|
||||
}
|
||||
let binding = self.table.get(&target)?.binding.clone();
|
||||
let result = self.provider.read_ticket(&context, binding.as_ref()).await;
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket snapshot"))?;
|
||||
Ok(result.and_then(|s| {
|
||||
if s.body.len() <= server_api::PLUGIN_TICKET_BODY_LIMIT {
|
||||
Ok(s)
|
||||
} else {
|
||||
Err(protocol())
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
impl data::Host for State {
|
||||
async fn get(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
cell_address: data::Address,
|
||||
) -> wasmtime::Result<Result<data::Cell, errors::HostError>> {
|
||||
Ok(
|
||||
match self
|
||||
.data_call(target, Op::Get(address(cell_address)))
|
||||
.await?
|
||||
{
|
||||
Ok(Reply::Cell(c)) => Ok(cell(c)),
|
||||
Err(e) => Err(e),
|
||||
_ => Err(protocol()),
|
||||
},
|
||||
)
|
||||
}
|
||||
async fn put(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
cell_address: data::Address,
|
||||
expected: data::Expectation,
|
||||
operation_key: String,
|
||||
value_json: String,
|
||||
) -> wasmtime::Result<Result<data::Receipt, errors::HostError>> {
|
||||
let expected = match expected {
|
||||
data::Expectation::NeverWritten => Expectation::NeverWritten,
|
||||
data::Expectation::LastWrite(id) => Expectation::LastWrite(id),
|
||||
};
|
||||
Ok(
|
||||
match self
|
||||
.data_call(
|
||||
target,
|
||||
Op::Mutate {
|
||||
address: address(cell_address),
|
||||
operation_key,
|
||||
mutation: Mutation::Put {
|
||||
expected,
|
||||
value_json,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(Reply::Mutated(r)) => Ok(receipt(r)),
|
||||
Err(e) => Err(e),
|
||||
_ => Err(protocol()),
|
||||
},
|
||||
)
|
||||
}
|
||||
async fn delete(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
cell_address: data::Address,
|
||||
expected_last_write: String,
|
||||
operation_key: String,
|
||||
) -> wasmtime::Result<Result<data::Receipt, errors::HostError>> {
|
||||
Ok(
|
||||
match self
|
||||
.data_call(
|
||||
target,
|
||||
Op::Mutate {
|
||||
address: address(cell_address),
|
||||
operation_key,
|
||||
mutation: Mutation::Delete {
|
||||
expected_last_write,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(Reply::Mutated(r)) => Ok(receipt(r)),
|
||||
Err(e) => Err(e),
|
||||
_ => Err(protocol()),
|
||||
},
|
||||
)
|
||||
}
|
||||
async fn list(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
namespace: data::Collection,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> wasmtime::Result<Result<data::Page, errors::HostError>> {
|
||||
Ok(
|
||||
match self
|
||||
.data_call(
|
||||
target,
|
||||
Op::List {
|
||||
collection: collection(namespace),
|
||||
cursor,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(Reply::Listed(p)) => Ok(data::Page {
|
||||
entries: p
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|e| data::Entry {
|
||||
key: e.key,
|
||||
value: cell(e.cell),
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: p.next_cursor,
|
||||
}),
|
||||
Err(e) => Err(e),
|
||||
_ => Err(protocol()),
|
||||
},
|
||||
)
|
||||
}
|
||||
async fn lookup_mutation(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
cell_address: data::Address,
|
||||
operation_key: String,
|
||||
) -> wasmtime::Result<Result<data::MutationLookup, errors::HostError>> {
|
||||
use server_api::PluginDataMutationLookup as Lookup;
|
||||
Ok(
|
||||
match self
|
||||
.data_call(
|
||||
target,
|
||||
Op::LookupMutation {
|
||||
address: address(cell_address),
|
||||
operation_key,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(Reply::MutationLookup(outcome)) => Ok(match outcome {
|
||||
Lookup::NotRecorded => data::MutationLookup::NotRecorded,
|
||||
Lookup::Expired => data::MutationLookup::Expired,
|
||||
Lookup::Committed(r) => data::MutationLookup::Committed(receipt(r)),
|
||||
}),
|
||||
Err(e) => Err(e),
|
||||
_ => Err(protocol()),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! cancellation-safe, bound their own allocations and never auto-retry effects.
|
||||
|
||||
pub use crate::bindings::yoi::host::{errors, http, logging};
|
||||
pub use crate::ticket_bindings::yoi::host2_1_0::{extension_data, objects, ticket_read};
|
||||
use async_trait::async_trait;
|
||||
use std::{any::Any, collections::BTreeSet, sync::Arc};
|
||||
use tokio::time::Instant;
|
||||
@@ -30,6 +31,9 @@ pub struct InvocationContext {
|
||||
pub struct Grants {
|
||||
pub logging: bool,
|
||||
pub connection_slots: BTreeSet<String>,
|
||||
pub ticket_read: bool,
|
||||
pub ticket_data_read: BTreeSet<(String, String, String)>,
|
||||
pub ticket_data_write: BTreeSet<(String, String, String)>,
|
||||
}
|
||||
|
||||
/// Opaque Host-owned capability, stored only within one invocation's Store.
|
||||
@@ -39,6 +43,11 @@ pub struct Connection {
|
||||
pub(crate) binding: Arc<dyn Any + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Opaque Ticket capability stored only in one invocation's Store.
|
||||
pub struct Ticket {
|
||||
pub(crate) binding: Arc<dyn Any + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Each operation requires a fresh owner check; admission covers pure Tools too.
|
||||
pub enum Operation<'a> {
|
||||
Admit,
|
||||
@@ -54,7 +63,8 @@ pub enum Operation<'a> {
|
||||
Log,
|
||||
}
|
||||
|
||||
/// No default implementation is supplied. Test providers belong to test targets.
|
||||
/// Core operations require implementations. Additive APIs default to Unsupported
|
||||
/// for old providers, never to permission or success. Test providers belong to tests.
|
||||
///
|
||||
/// `authorize` must check the current installation/artifact/plan/subject and
|
||||
/// operation against live owner policy. `open`/`send` must also enforce concrete
|
||||
@@ -63,6 +73,35 @@ pub enum Operation<'a> {
|
||||
/// dropped on cancellation/deadline; providers must not leave detached work.
|
||||
#[async_trait]
|
||||
pub trait Host: Send + Sync {
|
||||
/// Old providers fail closed, never infer permission from an old grant.
|
||||
async fn resolve_ticket(
|
||||
&self,
|
||||
_context: &InvocationContext,
|
||||
_ticket_ref: &str,
|
||||
_access: objects::Access,
|
||||
) -> Result<Arc<dyn Any + Send + Sync>, errors::HostError> {
|
||||
Err(error(errors::Code::Unsupported, "Ticket API unavailable"))
|
||||
}
|
||||
async fn read_ticket(
|
||||
&self,
|
||||
_context: &InvocationContext,
|
||||
_binding: &(dyn Any + Send + Sync),
|
||||
) -> Result<ticket_read::Snapshot, errors::HostError> {
|
||||
Err(error(errors::Code::Unsupported, "Ticket API unavailable"))
|
||||
}
|
||||
/// Providers live-authorize the concrete operation, Ticket and namespace.
|
||||
async fn ticket_data(
|
||||
&self,
|
||||
_context: &InvocationContext,
|
||||
_binding: &(dyn Any + Send + Sync),
|
||||
_operation: TicketDataOperation,
|
||||
) -> Result<TicketDataResult, errors::HostError> {
|
||||
Err(error(
|
||||
errors::Code::Unsupported,
|
||||
"Ticket data API unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
@@ -94,3 +133,30 @@ pub(crate) fn error(code: errors::Code, message: &str) -> errors::HostError {
|
||||
effect: errors::EffectObservation::NotDispatched,
|
||||
}
|
||||
}
|
||||
|
||||
/// Portable provider boundary shared by Remote and Embedded owners.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TicketDataOperation {
|
||||
Get(server_api::PluginDataAddress),
|
||||
Mutate {
|
||||
address: server_api::PluginDataAddress,
|
||||
operation_key: String,
|
||||
mutation: server_api::PluginDataMutation,
|
||||
},
|
||||
List {
|
||||
collection: server_api::PluginDataCollection,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
},
|
||||
LookupMutation {
|
||||
address: server_api::PluginDataAddress,
|
||||
operation_key: String,
|
||||
},
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub enum TicketDataResult {
|
||||
Cell(server_api::PluginDataCell),
|
||||
Mutated(server_api::PluginDataReceipt),
|
||||
Listed(server_api::PluginDataPage),
|
||||
MutationLookup(server_api::PluginDataMutationLookup),
|
||||
}
|
||||
|
||||
@@ -16,3 +16,17 @@ pub mod host;
|
||||
pub mod owner;
|
||||
mod sandbox;
|
||||
pub use executor::*;
|
||||
|
||||
/// Additive Ticket imports share the original v2 error types.
|
||||
pub mod ticket_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../../resources/plugin/wit-ticket",
|
||||
world: "ticket-tool-plugin",
|
||||
imports: { default: async | trappable },
|
||||
exports: { default: async },
|
||||
with: {
|
||||
"yoi:host/errors@2.0.0": crate::bindings::yoi::host::errors,
|
||||
"yoi:host/objects@2.1.0/ticket": crate::host::Ticket,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Shared owner transport and WIT Host adapter. In-process owners implement the
|
||||
//! same trait directly; a failed remote call never selects a local fallback.
|
||||
use crate::host::{Host, InvocationContext, Operation, errors, http, logging};
|
||||
use crate::host::{
|
||||
Host, InvocationContext, Operation, TicketDataOperation, TicketDataResult, errors, http,
|
||||
logging, objects, ticket_read,
|
||||
};
|
||||
mod ticket;
|
||||
use async_trait::async_trait;
|
||||
use server_api::{
|
||||
PluginConsumer, PluginEffectObservation, PluginHostError, PluginHostErrorCode,
|
||||
@@ -12,6 +16,7 @@ use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
pub(crate) use ticket::validate_operation;
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// Bound to one authenticated caller and Workspace by the integration. The owner
|
||||
@@ -44,11 +49,12 @@ pub trait PluginHostOwner: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Finite generated-client response budget for the supported one-MiB binary
|
||||
/// body, its worst-case JSON numeric-array expansion, headers and envelope.
|
||||
/// Finite generated-client response budget: a 100-cell page of 64-KiB JSON
|
||||
/// strings, their wire escaping, plus keys/receipt metadata; also covers the
|
||||
/// original one-MiB binary HTTP body and numeric-array expansion.
|
||||
/// Set `response_body_limit` on the injected client before constructing a Remote
|
||||
/// owner; a smaller operator-selected limit may reject otherwise valid replies.
|
||||
pub const PLUGIN_HOST_RESPONSE_BODY_LIMIT: usize = 4 * 1024 * 1024 + 512 * 1024;
|
||||
pub const PLUGIN_HOST_RESPONSE_BODY_LIMIT: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// Uses an injected, already configured generated client (including its existing
|
||||
/// Runtime request authorizer). This adapter never creates or owns credentials.
|
||||
@@ -72,7 +78,10 @@ impl<A: server_api::client_support::RequestAuthorizer> PluginHostOwner
|
||||
&self,
|
||||
request: PluginHostRequest,
|
||||
) -> Result<PluginHostResponse, PluginHostError> {
|
||||
let may_dispatch = matches!(request, PluginHostRequest::Send { .. });
|
||||
let may_dispatch = matches!(
|
||||
request,
|
||||
PluginHostRequest::Send { .. } | PluginHostRequest::DataMutate { .. }
|
||||
);
|
||||
self.client
|
||||
.plugin_host(self.workspace_id.clone(), request)
|
||||
.await
|
||||
@@ -210,6 +219,9 @@ impl OwnerHost {
|
||||
|| context.invocation_id != bound.invocation_id
|
||||
|| context.artifact != bound.artifact
|
||||
|| context.tool_name != bound.tool_name
|
||||
|| context.grants.ticket_read != bound.grants.ticket_read
|
||||
|| context.grants.ticket_data_read != bound.grants.ticket_data_read
|
||||
|| context.grants.ticket_data_write != bound.grants.ticket_data_write
|
||||
|| context.grants.logging != bound.grants.logging
|
||||
|| context.grants.connection_slots != bound.grants.connection_slots
|
||||
// The executor may shorten the deadline and derive a child token.
|
||||
@@ -253,6 +265,36 @@ impl OwnerHost {
|
||||
|
||||
#[async_trait]
|
||||
impl Host for OwnerHost {
|
||||
async fn resolve_ticket(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
ticket_ref: &str,
|
||||
access: objects::Access,
|
||||
) -> Result<Arc<dyn Any + Send + Sync>, errors::HostError> {
|
||||
self.resolve_ticket_binding(context, ticket_ref, access)
|
||||
.await
|
||||
.map_err(wit_error)
|
||||
}
|
||||
async fn read_ticket(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
binding: &(dyn Any + Send + Sync),
|
||||
) -> Result<ticket_read::Snapshot, errors::HostError> {
|
||||
self.read_ticket_binding(context, binding)
|
||||
.await
|
||||
.map_err(wit_error)
|
||||
}
|
||||
async fn ticket_data(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
binding: &(dyn Any + Send + Sync),
|
||||
operation: TicketDataOperation,
|
||||
) -> Result<TicketDataResult, errors::HostError> {
|
||||
self.ticket_data_binding(context, binding, operation)
|
||||
.await
|
||||
.map_err(wit_error)
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
@@ -447,7 +489,10 @@ async fn call_owner(
|
||||
request: PluginHostRequest,
|
||||
) -> Result<PluginHostResponse, PluginHostError> {
|
||||
check_lifetime(context)?;
|
||||
let effect = if matches!(request, PluginHostRequest::Send { .. }) {
|
||||
let effect = if matches!(
|
||||
request,
|
||||
PluginHostRequest::Send { .. } | PluginHostRequest::DataMutate { .. }
|
||||
) {
|
||||
PluginEffectObservation::Indeterminate
|
||||
} else {
|
||||
PluginEffectObservation::NotDispatched
|
||||
@@ -588,6 +633,40 @@ mod tests {
|
||||
},
|
||||
},
|
||||
PluginHostRequest::Log { .. } => PluginHostResponse::Logged,
|
||||
PluginHostRequest::ResolveTicket { .. } => PluginHostResponse::TicketResolved {
|
||||
ticket_id: "canonical-ticket".into(),
|
||||
},
|
||||
PluginHostRequest::ReadTicket { .. } => PluginHostResponse::TicketRead {
|
||||
snapshot: server_api::PluginTicketSnapshot {
|
||||
title: "title".into(),
|
||||
case_state: server_api::PluginTicketCaseState::Open,
|
||||
content_digest: "digest".into(),
|
||||
body: "bounded".into(),
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
|
||||
cell: server_api::PluginDataCell::NeverWritten,
|
||||
},
|
||||
PluginHostRequest::DataMutate { operation_key, .. } => {
|
||||
PluginHostResponse::DataMutated {
|
||||
receipt: server_api::PluginDataReceipt {
|
||||
mutation_id: operation_key,
|
||||
result_digest: "digest".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
PluginHostRequest::DataList { .. } => PluginHostResponse::DataListed {
|
||||
page: server_api::PluginDataPage {
|
||||
entries: vec![],
|
||||
next_cursor: None,
|
||||
},
|
||||
},
|
||||
PluginHostRequest::DataLookupMutation { .. } => {
|
||||
PluginHostResponse::DataMutationLookup {
|
||||
outcome: server_api::PluginDataMutationLookup::NotRecorded,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -607,6 +686,7 @@ mod tests {
|
||||
grants: Grants {
|
||||
logging: true,
|
||||
connection_slots: ["api".into(), "other".into()].into(),
|
||||
..Default::default()
|
||||
},
|
||||
deadline: Instant::now() + Duration::from_secs(60),
|
||||
cancellation: CancellationToken::new(),
|
||||
@@ -860,6 +940,192 @@ mod tests {
|
||||
assert_eq!(owner.requests.lock().unwrap().len(), count + 1);
|
||||
}
|
||||
|
||||
fn ticket_context() -> InvocationContext {
|
||||
let mut c = context();
|
||||
c.grants.ticket_read = true;
|
||||
c.grants
|
||||
.ticket_data_read
|
||||
.insert(("notes".into(), "note".into(), "1".into()));
|
||||
c.grants.ticket_data_write = c.grants.ticket_data_read.clone();
|
||||
c
|
||||
}
|
||||
fn cell_address() -> server_api::PluginDataAddress {
|
||||
server_api::PluginDataAddress {
|
||||
collection: server_api::PluginDataCollection {
|
||||
collection: "notes".into(),
|
||||
schema_id: "note".into(),
|
||||
schema_version: "1".into(),
|
||||
},
|
||||
key: "key".into(),
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn remote_and_direct_ticket_resources_reauthorize_and_reject_foreign_handles() {
|
||||
let mut transcripts = Vec::new();
|
||||
for use_remote in [false, true] {
|
||||
let owner = Arc::new(TestOwner::default());
|
||||
let (transport, _endpoint): (Arc<dyn PluginHostOwner>, _) = if use_remote {
|
||||
let (t, e) = remote(owner.clone()).await;
|
||||
(t, Some(e))
|
||||
} else {
|
||||
(owner.clone(), None)
|
||||
};
|
||||
let host = OwnerHost::begin(transport.clone(), ticket_context(), consumer())
|
||||
.await
|
||||
.unwrap();
|
||||
let foreign = OwnerHost::begin(transport, ticket_context(), consumer())
|
||||
.await
|
||||
.unwrap();
|
||||
let resource = host
|
||||
.resolve_ticket(host.context(), "T-795", objects::Access::ExtensionWrite)
|
||||
.await
|
||||
.unwrap();
|
||||
let other = foreign
|
||||
.resolve_ticket(foreign.context(), "T-795", objects::Access::ExtensionWrite)
|
||||
.await
|
||||
.unwrap();
|
||||
// Test owner deliberately reuses invocation IDs. Private Arc identity,
|
||||
// not the string or canonical Ticket ID, still rejects foreign use.
|
||||
let count = owner.requests.lock().unwrap().len();
|
||||
assert_eq!(
|
||||
host.read_ticket(host.context(), other.as_ref())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
errors::Code::Denied
|
||||
);
|
||||
assert_eq!(
|
||||
host.ticket_data(
|
||||
host.context(),
|
||||
other.as_ref(),
|
||||
TicketDataOperation::Get(cell_address())
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
errors::Code::Denied
|
||||
);
|
||||
let mut widened = host.context().clone();
|
||||
widened
|
||||
.grants
|
||||
.ticket_data_read
|
||||
.insert(("foreign".into(), "note".into(), "1".into()));
|
||||
assert_eq!(
|
||||
host.read_ticket(&widened, resource.as_ref())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
errors::Code::Denied
|
||||
);
|
||||
assert_eq!(owner.requests.lock().unwrap().len(), count);
|
||||
assert_eq!(
|
||||
host.read_ticket(host.context(), resource.as_ref())
|
||||
.await
|
||||
.unwrap()
|
||||
.body,
|
||||
"bounded"
|
||||
);
|
||||
let read_only = host
|
||||
.resolve_ticket(host.context(), "T-795", objects::Access::Read)
|
||||
.await
|
||||
.unwrap();
|
||||
let mutation = TicketDataOperation::Mutate {
|
||||
address: cell_address(),
|
||||
operation_key: "write".into(),
|
||||
mutation: server_api::PluginDataMutation::Put {
|
||||
expected: server_api::PluginDataExpectation::NeverWritten,
|
||||
value_json: "{}".into(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
host.ticket_data(host.context(), read_only.as_ref(), mutation.clone())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
errors::Code::Denied
|
||||
);
|
||||
assert!(matches!(
|
||||
host.ticket_data(host.context(), resource.as_ref(), mutation)
|
||||
.await
|
||||
.unwrap(),
|
||||
TicketDataResult::Mutated(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
host.ticket_data(
|
||||
host.context(),
|
||||
resource.as_ref(),
|
||||
TicketDataOperation::Get(cell_address())
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
TicketDataResult::Cell(_)
|
||||
));
|
||||
host.ticket_data(
|
||||
host.context(),
|
||||
resource.as_ref(),
|
||||
TicketDataOperation::List {
|
||||
collection: cell_address().collection,
|
||||
cursor: None,
|
||||
limit: 10,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
host.ticket_data(
|
||||
host.context(),
|
||||
resource.as_ref(),
|
||||
TicketDataOperation::LookupMutation {
|
||||
address: cell_address(),
|
||||
operation_key: "write".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut wrong_schema = cell_address();
|
||||
wrong_schema.collection.schema_version = "2".into();
|
||||
assert_eq!(
|
||||
host.ticket_data(
|
||||
host.context(),
|
||||
resource.as_ref(),
|
||||
TicketDataOperation::Get(wrong_schema)
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
errors::Code::Denied
|
||||
);
|
||||
owner.revoked.store(true, Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
host.read_ticket(host.context(), resource.as_ref())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
errors::Code::Denied
|
||||
);
|
||||
assert_eq!(
|
||||
host.ticket_data(
|
||||
host.context(),
|
||||
resource.as_ref(),
|
||||
TicketDataOperation::Get(cell_address())
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
errors::Code::Denied
|
||||
);
|
||||
host.context().cancellation.cancel();
|
||||
assert_eq!(
|
||||
host.read_ticket(host.context(), resource.as_ref())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
errors::Code::Cancelled
|
||||
);
|
||||
transcripts.push(owner.requests.lock().unwrap().clone());
|
||||
}
|
||||
assert_eq!(transcripts[0], transcripts[1]);
|
||||
}
|
||||
|
||||
struct PendingOwner {
|
||||
inner: TestOwner,
|
||||
begin: bool,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Invocation-local resource identity is independent of owner Ticket selectors.
|
||||
use super::*;
|
||||
use server_api::*;
|
||||
|
||||
struct OwnerTicket {
|
||||
invocation_id: String,
|
||||
ticket_id: String,
|
||||
writable: bool,
|
||||
identity: Arc<()>,
|
||||
}
|
||||
fn denied() -> PluginHostError {
|
||||
local_error(
|
||||
PluginHostErrorCode::Denied,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
)
|
||||
}
|
||||
fn invalid() -> PluginHostError {
|
||||
local_error(
|
||||
PluginHostErrorCode::InvalidInput,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
)
|
||||
}
|
||||
impl OwnerHost {
|
||||
fn ticket<'a>(
|
||||
&self,
|
||||
binding: &'a (dyn Any + Send + Sync),
|
||||
) -> Result<&'a OwnerTicket, PluginHostError> {
|
||||
binding
|
||||
.downcast_ref::<OwnerTicket>()
|
||||
.filter(|t| {
|
||||
t.invocation_id == self.context.invocation_id
|
||||
&& Arc::ptr_eq(&t.identity, &self.binding_identity)
|
||||
})
|
||||
.ok_or_else(denied)
|
||||
}
|
||||
pub(super) async fn resolve_ticket_binding(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
ticket_ref: &str,
|
||||
access: objects::Access,
|
||||
) -> Result<Arc<dyn Any + Send + Sync>, PluginHostError> {
|
||||
self.check_context(context)?;
|
||||
if ticket_ref.is_empty() || ticket_ref.len() > 256 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let writable = matches!(access, objects::Access::ExtensionWrite);
|
||||
if if writable {
|
||||
context.grants.ticket_data_write.is_empty()
|
||||
} else {
|
||||
!context.grants.ticket_read && context.grants.ticket_data_read.is_empty()
|
||||
} {
|
||||
return Err(denied());
|
||||
}
|
||||
match call_owner(
|
||||
self.owner.as_ref(),
|
||||
context,
|
||||
PluginHostRequest::ResolveTicket {
|
||||
invocation_id: context.invocation_id.clone(),
|
||||
ticket_ref: ticket_ref.into(),
|
||||
access: if writable {
|
||||
PluginTicketAccess::ExtensionWrite
|
||||
} else {
|
||||
PluginTicketAccess::Read
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
PluginHostResponse::TicketResolved { ticket_id }
|
||||
if !ticket_id.is_empty() && ticket_id.len() <= 256 =>
|
||||
{
|
||||
Ok(Arc::new(OwnerTicket {
|
||||
invocation_id: context.invocation_id.clone(),
|
||||
ticket_id,
|
||||
writable,
|
||||
identity: self.binding_identity.clone(),
|
||||
}))
|
||||
}
|
||||
_ => Err(protocol_error(false)),
|
||||
}
|
||||
}
|
||||
pub(super) async fn read_ticket_binding(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
binding: &(dyn Any + Send + Sync),
|
||||
) -> Result<ticket_read::Snapshot, PluginHostError> {
|
||||
self.check_context(context)?;
|
||||
let ticket = self.ticket(binding)?;
|
||||
if !context.grants.ticket_read {
|
||||
return Err(denied());
|
||||
}
|
||||
match call_owner(
|
||||
self.owner.as_ref(),
|
||||
context,
|
||||
PluginHostRequest::ReadTicket {
|
||||
invocation_id: context.invocation_id.clone(),
|
||||
ticket_id: ticket.ticket_id.clone(),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
PluginHostResponse::TicketRead { snapshot }
|
||||
if snapshot.body.len() <= PLUGIN_TICKET_BODY_LIMIT =>
|
||||
{
|
||||
Ok(ticket_read::Snapshot {
|
||||
title: snapshot.title,
|
||||
state: match snapshot.case_state {
|
||||
PluginTicketCaseState::Open => ticket_read::CaseState::Open,
|
||||
PluginTicketCaseState::Closed => ticket_read::CaseState::Closed,
|
||||
},
|
||||
content_digest: snapshot.content_digest,
|
||||
body: snapshot.body,
|
||||
truncated: snapshot.truncated,
|
||||
})
|
||||
}
|
||||
_ => Err(protocol_error(false)),
|
||||
}
|
||||
}
|
||||
pub(super) async fn ticket_data_binding(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
binding: &(dyn Any + Send + Sync),
|
||||
operation: TicketDataOperation,
|
||||
) -> Result<TicketDataResult, PluginHostError> {
|
||||
self.check_context(context)?;
|
||||
let ticket = self.ticket(binding)?;
|
||||
let (collection, write) = match &operation {
|
||||
TicketDataOperation::Get(a)
|
||||
| TicketDataOperation::LookupMutation { address: a, .. } => (&a.collection, false),
|
||||
TicketDataOperation::Mutate { address: a, .. } => (&a.collection, true),
|
||||
TicketDataOperation::List { collection, .. } => (collection, false),
|
||||
};
|
||||
let identity = (
|
||||
collection.collection.clone(),
|
||||
collection.schema_id.clone(),
|
||||
collection.schema_version.clone(),
|
||||
);
|
||||
let lookup = matches!(&operation, TicketDataOperation::LookupMutation { .. });
|
||||
let grants = if write || lookup {
|
||||
&context.grants.ticket_data_write
|
||||
} else {
|
||||
&context.grants.ticket_data_read
|
||||
};
|
||||
if !grants.contains(&identity) || ((write || lookup) && !ticket.writable) {
|
||||
return Err(denied());
|
||||
}
|
||||
validate_operation(&operation)?;
|
||||
let invocation_id = context.invocation_id.clone();
|
||||
let ticket_id = ticket.ticket_id.clone();
|
||||
let mutation = matches!(&operation, TicketDataOperation::Mutate { .. });
|
||||
let request = match operation {
|
||||
TicketDataOperation::Get(address) => PluginHostRequest::DataGet {
|
||||
invocation_id,
|
||||
ticket_id,
|
||||
address,
|
||||
},
|
||||
TicketDataOperation::Mutate {
|
||||
address,
|
||||
operation_key,
|
||||
mutation,
|
||||
} => PluginHostRequest::DataMutate {
|
||||
invocation_id,
|
||||
ticket_id,
|
||||
address,
|
||||
operation_key,
|
||||
mutation,
|
||||
},
|
||||
TicketDataOperation::List {
|
||||
collection,
|
||||
cursor,
|
||||
limit,
|
||||
} => PluginHostRequest::DataList {
|
||||
invocation_id,
|
||||
ticket_id,
|
||||
collection,
|
||||
cursor,
|
||||
limit,
|
||||
},
|
||||
TicketDataOperation::LookupMutation {
|
||||
address,
|
||||
operation_key,
|
||||
} => PluginHostRequest::DataLookupMutation {
|
||||
invocation_id,
|
||||
ticket_id,
|
||||
address,
|
||||
operation_key,
|
||||
},
|
||||
};
|
||||
match call_owner(self.owner.as_ref(), context, request.clone()).await? {
|
||||
PluginHostResponse::DataCell { cell }
|
||||
if matches!(request, PluginHostRequest::DataGet { .. }) =>
|
||||
{
|
||||
Ok(TicketDataResult::Cell(cell))
|
||||
}
|
||||
PluginHostResponse::DataMutated { receipt } if mutation => {
|
||||
Ok(TicketDataResult::Mutated(receipt))
|
||||
}
|
||||
PluginHostResponse::DataListed { page }
|
||||
if matches!(request, PluginHostRequest::DataList { .. }) =>
|
||||
{
|
||||
Ok(TicketDataResult::Listed(page))
|
||||
}
|
||||
PluginHostResponse::DataMutationLookup { outcome }
|
||||
if matches!(request, PluginHostRequest::DataLookupMutation { .. }) =>
|
||||
{
|
||||
Ok(TicketDataResult::MutationLookup(outcome))
|
||||
}
|
||||
_ => Err(protocol_error(mutation)),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn text(s: &str, limit: usize) -> bool {
|
||||
!s.is_empty() && s.len() <= limit && !s.chars().any(char::is_control)
|
||||
}
|
||||
pub(crate) fn validate_operation(operation: &TicketDataOperation) -> Result<(), PluginHostError> {
|
||||
let collection = match operation {
|
||||
TicketDataOperation::Get(a)
|
||||
| TicketDataOperation::Mutate { address: a, .. }
|
||||
| TicketDataOperation::LookupMutation { address: a, .. } => {
|
||||
if !text(&a.key, 256) {
|
||||
return Err(invalid());
|
||||
}
|
||||
&a.collection
|
||||
}
|
||||
TicketDataOperation::List {
|
||||
collection,
|
||||
cursor,
|
||||
limit,
|
||||
} => {
|
||||
if *limit == 0
|
||||
|| *limit > PLUGIN_DATA_LIST_LIMIT
|
||||
|| cursor.as_ref().is_some_and(|c| !text(c, 4096))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
collection
|
||||
}
|
||||
};
|
||||
if [
|
||||
&collection.collection,
|
||||
&collection.schema_id,
|
||||
&collection.schema_version,
|
||||
]
|
||||
.iter()
|
||||
.any(|s| !text(s, 128))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
match operation {
|
||||
TicketDataOperation::Mutate {
|
||||
operation_key,
|
||||
mutation,
|
||||
..
|
||||
} => {
|
||||
if !text(operation_key, 256) {
|
||||
return Err(invalid());
|
||||
}
|
||||
match mutation {
|
||||
PluginDataMutation::Put {
|
||||
expected,
|
||||
value_json,
|
||||
} => {
|
||||
if value_json.len() > PLUGIN_DATA_VALUE_LIMIT
|
||||
|| serde_json::from_str::<serde_json::Value>(value_json).is_err()
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
if let PluginDataExpectation::LastWrite(id) = expected {
|
||||
if !text(id, 256) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
PluginDataMutation::Delete {
|
||||
expected_last_write,
|
||||
} => {
|
||||
if !text(expected_last_write, 256) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TicketDataOperation::LookupMutation { operation_key, .. } if !text(operation_key, 256) => {
|
||||
return Err(invalid());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -111,6 +111,7 @@ fn context(component: &CompiledTool, id: &str) -> InvocationContext {
|
||||
grants: Grants {
|
||||
logging: true,
|
||||
connection_slots: BTreeSet::from(["api".into()]),
|
||||
..Default::default()
|
||||
},
|
||||
deadline: Instant::now() + Duration::from_secs(10),
|
||||
cancellation: CancellationToken::new(),
|
||||
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "ticket-test-guest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn 2.0.119",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoi-plugin-pdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "ticket-test-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
|
||||
@@ -0,0 +1,38 @@
|
||||
schema_version = 2
|
||||
id = "ticket-test"
|
||||
name = "Ticket test"
|
||||
version = "1.0.0"
|
||||
surfaces = ["tool"]
|
||||
config_schema = "{}"
|
||||
|
||||
[runtime]
|
||||
kind = "wasm-component"
|
||||
component = "plugin.component.wasm"
|
||||
world = "yoi:plugin/ticket-tool-plugin@2.1.0"
|
||||
required_imports = ["yoi:host/errors@2.0.0", "yoi:host/objects@2.1.0", "yoi:host/ticket-read@2.1.0", "yoi:host/extension-data@2.1.0"]
|
||||
|
||||
[[tools]]
|
||||
name = "ticket_test"
|
||||
description = "Exercise the typed Ticket extension boundary"
|
||||
input_schema = { type = "string" }
|
||||
output_schema = { type = "null" }
|
||||
error_codes = ["failed"]
|
||||
|
||||
[[data_collections]]
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
schema = { type = "string", maxLength = 64 }
|
||||
|
||||
[[requested_grants]]
|
||||
kind = "ticket_read"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_data_read"
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_data_write"
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
@@ -0,0 +1,95 @@
|
||||
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput, wit_bindgen};
|
||||
wit_bindgen::generate!({
|
||||
path: "../../../../resources/plugin/wit-ticket",
|
||||
world: "ticket-tool-plugin",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
use yoi::host2_1_0::{extension_data as data, objects, ticket_read};
|
||||
|
||||
fn namespace() -> data::Collection {
|
||||
data::Collection {
|
||||
collection: "notes".into(),
|
||||
schema_id: "note".into(),
|
||||
schema_version: "1".into(),
|
||||
}
|
||||
}
|
||||
fn address() -> data::Address {
|
||||
data::Address {
|
||||
namespace: namespace(),
|
||||
key: "main".into(),
|
||||
}
|
||||
}
|
||||
fn failure(error: yoi::host2_0_0::errors::HostError) -> ToolError {
|
||||
ToolError::failed(format!("{:?}", error.code))
|
||||
}
|
||||
fn handle(_: ToolContext, mode: String) -> yoi_plugin_pdk::Result<ToolOutput> {
|
||||
let ticket = if mode == "foreign" {
|
||||
unsafe { objects::Ticket::from_handle(0) }
|
||||
} else {
|
||||
objects::resolve_ticket("T-795", objects::Access::ExtensionWrite).map_err(failure)?
|
||||
};
|
||||
let ticket = if mode == "stale" {
|
||||
let handle = ticket.handle();
|
||||
drop(ticket);
|
||||
unsafe { objects::Ticket::from_handle(handle) }
|
||||
} else {
|
||||
ticket
|
||||
};
|
||||
let snapshot = ticket_read::read(&ticket).map_err(failure)?;
|
||||
if mode == "other-schema" {
|
||||
let mut a = address();
|
||||
a.namespace.schema_version = "2".into();
|
||||
data::get(&ticket, &a).map_err(failure)?;
|
||||
}
|
||||
if mode == "read" {
|
||||
return ToolOutput::json(snapshot.title, ());
|
||||
}
|
||||
let json = if mode == "bad-schema" {
|
||||
"{\"undeclared\":true}".into()
|
||||
} else {
|
||||
yoi_plugin_pdk::extension_data_json(&"note")?
|
||||
};
|
||||
let written = data::put(
|
||||
&ticket,
|
||||
&address(),
|
||||
&data::Expectation::NeverWritten,
|
||||
"put",
|
||||
&json,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
assert!(matches!(
|
||||
data::get(&ticket, &address()).map_err(failure)?,
|
||||
data::Cell::Present(_)
|
||||
));
|
||||
let page = data::list(&ticket, &namespace(), None, 10).map_err(failure)?;
|
||||
assert_eq!(page.entries.len(), 1);
|
||||
let lookup = data::lookup_mutation(&ticket, &address(), "put").map_err(failure)?;
|
||||
assert!(matches!(lookup, data::MutationLookup::Committed(_)));
|
||||
let deleted =
|
||||
data::delete(&ticket, &address(), &written.mutation_id, "delete").map_err(failure)?;
|
||||
assert!(matches!(
|
||||
data::get(&ticket, &address()).map_err(failure)?,
|
||||
data::Cell::Deleted(_)
|
||||
));
|
||||
data::put(
|
||||
&ticket,
|
||||
&address(),
|
||||
&data::Expectation::LastWrite(deleted.mutation_id),
|
||||
"recreate",
|
||||
&json,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
// Replay the original exact request even though the cell has since changed.
|
||||
let replay = data::put(
|
||||
&ticket,
|
||||
&address(),
|
||||
&data::Expectation::NeverWritten,
|
||||
"put",
|
||||
&json,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
assert_eq!(replay.mutation_id, written.mutation_id);
|
||||
ToolOutput::json(snapshot.title, ())
|
||||
}
|
||||
yoi_plugin_pdk::export_ticket_component_tool!(TicketPlugin, handle);
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Real Rust PDK -> Component -> Wasmtime -> OwnerHost adapter regression.
|
||||
//! The deterministic owner here tests the adapter/ABI, not DB durability or
|
||||
//! production authorization; real owner/storage races belong to workspace-server.
|
||||
use async_trait::async_trait;
|
||||
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
|
||||
use server_api::*;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::Path,
|
||||
process::Command,
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::time::Instant;
|
||||
use yoi_plugin_runtime::{
|
||||
host::{CancellationToken, Grants, InvocationContext},
|
||||
owner::{OwnerHost, PluginHostOwner},
|
||||
*,
|
||||
};
|
||||
|
||||
fn component() -> &'static [u8] {
|
||||
static COMPONENT: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
COMPONENT.get_or_init(|| {
|
||||
// Fixture builds write only this explicitly isolated output directory.
|
||||
let target =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/t795-contracts/guest");
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.args([
|
||||
"build",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"--release",
|
||||
"--target",
|
||||
"wasm32-unknown-unknown",
|
||||
"--manifest-path",
|
||||
])
|
||||
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/ticket-guest/Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let core =
|
||||
fs::read(target.join("wasm32-unknown-unknown/release/ticket_test_guest.wasm")).unwrap();
|
||||
let bytes = wit_component::ComponentEncoder::default()
|
||||
.module(&core)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap();
|
||||
fs::write(
|
||||
target.parent().unwrap().join("ticket-tool.component.wasm"),
|
||||
&bytes,
|
||||
)
|
||||
.unwrap();
|
||||
bytes
|
||||
})
|
||||
}
|
||||
fn artifact(manifest: &str) -> plugin::CanonicalPluginArtifact {
|
||||
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/t795-contracts");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
let dir = tempfile::tempdir_in(target).unwrap();
|
||||
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
|
||||
fs::write(dir.path().join("plugin.component.wasm"), component()).unwrap();
|
||||
let package = plugin::read_plugin_directory(
|
||||
dir.path(),
|
||||
PluginSourceKind::Explicit,
|
||||
&PluginPackageLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
plugin::pack_plugin_package(&package, &PluginPackageLimits::default()).unwrap()
|
||||
}
|
||||
struct State {
|
||||
cell: PluginDataCell,
|
||||
receipts: BTreeMap<String, (PluginHostRequest, PluginDataReceipt)>,
|
||||
calls: Vec<PluginHostRequest>,
|
||||
}
|
||||
struct Owner {
|
||||
state: Mutex<State>,
|
||||
revoke_after_resolve: bool,
|
||||
}
|
||||
impl Owner {
|
||||
fn new(revoke_after_resolve: bool) -> Self {
|
||||
Self {
|
||||
state: Mutex::new(State {
|
||||
cell: PluginDataCell::NeverWritten,
|
||||
receipts: BTreeMap::new(),
|
||||
calls: vec![],
|
||||
}),
|
||||
revoke_after_resolve,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn rejected(code: PluginHostErrorCode) -> PluginHostError {
|
||||
PluginHostError {
|
||||
code,
|
||||
effect: PluginEffectObservation::NotDispatched,
|
||||
diagnostic_id: "isolated-test".into(),
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl PluginHostOwner for Owner {
|
||||
async fn call(
|
||||
&self,
|
||||
request: PluginHostRequest,
|
||||
) -> Result<PluginHostResponse, PluginHostError> {
|
||||
let mut s = self.state.lock().unwrap();
|
||||
s.calls.push(request.clone());
|
||||
Ok(match request.clone() {
|
||||
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
|
||||
invocation_id: "logical-invocation".into(),
|
||||
expires_at_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64
|
||||
+ 60_000,
|
||||
},
|
||||
PluginHostRequest::Authorize { .. } => PluginHostResponse::Authorized,
|
||||
PluginHostRequest::ResolveTicket { ticket_ref, .. } => {
|
||||
assert_eq!(ticket_ref, "T-795");
|
||||
PluginHostResponse::TicketResolved {
|
||||
ticket_id: "canonical-ticket".into(),
|
||||
}
|
||||
}
|
||||
PluginHostRequest::ReadTicket { ticket_id, .. } => {
|
||||
assert_eq!(ticket_id, "canonical-ticket");
|
||||
if self.revoke_after_resolve {
|
||||
return Err(rejected(PluginHostErrorCode::Denied));
|
||||
}
|
||||
PluginHostResponse::TicketRead {
|
||||
snapshot: PluginTicketSnapshot {
|
||||
title: "unchanged title".into(),
|
||||
case_state: PluginTicketCaseState::Open,
|
||||
content_digest: "unchanged digest".into(),
|
||||
body: "bounded body".into(),
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
|
||||
cell: s.cell.clone(),
|
||||
},
|
||||
PluginHostRequest::DataList { .. } => PluginHostResponse::DataListed {
|
||||
page: PluginDataPage {
|
||||
entries: vec![PluginDataEntry {
|
||||
key: "main".into(),
|
||||
cell: s.cell.clone(),
|
||||
}],
|
||||
next_cursor: None,
|
||||
},
|
||||
},
|
||||
PluginHostRequest::DataLookupMutation { operation_key, .. } => {
|
||||
PluginHostResponse::DataMutationLookup {
|
||||
outcome: s
|
||||
.receipts
|
||||
.get(&operation_key)
|
||||
.map(|(_, r)| PluginDataMutationLookup::Committed(r.clone()))
|
||||
.unwrap_or(PluginDataMutationLookup::NotRecorded),
|
||||
}
|
||||
}
|
||||
PluginHostRequest::DataMutate {
|
||||
operation_key,
|
||||
mutation,
|
||||
..
|
||||
} => {
|
||||
if let Some((original, receipt)) = s.receipts.get(&operation_key) {
|
||||
if *original != request {
|
||||
return Err(rejected(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
return Ok(PluginHostResponse::DataMutated {
|
||||
receipt: receipt.clone(),
|
||||
});
|
||||
}
|
||||
let observed = match &s.cell {
|
||||
PluginDataCell::NeverWritten => PluginDataExpectation::NeverWritten,
|
||||
PluginDataCell::Present { last_write, .. } => {
|
||||
PluginDataExpectation::LastWrite(last_write.clone())
|
||||
}
|
||||
PluginDataCell::Deleted { last_delete } => {
|
||||
PluginDataExpectation::LastWrite(last_delete.clone())
|
||||
}
|
||||
};
|
||||
let id = format!("mutation-{operation_key}");
|
||||
s.cell = match mutation {
|
||||
PluginDataMutation::Put {
|
||||
expected,
|
||||
value_json,
|
||||
} => {
|
||||
if expected != observed {
|
||||
return Err(rejected(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
PluginDataCell::Present {
|
||||
value_json,
|
||||
last_write: id.clone(),
|
||||
}
|
||||
}
|
||||
PluginDataMutation::Delete {
|
||||
expected_last_write,
|
||||
} => {
|
||||
if PluginDataExpectation::LastWrite(expected_last_write) != observed {
|
||||
return Err(rejected(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
PluginDataCell::Deleted {
|
||||
last_delete: id.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let receipt = PluginDataReceipt {
|
||||
mutation_id: id,
|
||||
result_digest: "result-digest".into(),
|
||||
};
|
||||
s.receipts.insert(operation_key, (request, receipt.clone()));
|
||||
PluginHostResponse::DataMutated { receipt }
|
||||
}
|
||||
_ => return Err(rejected(PluginHostErrorCode::Unsupported)),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn context(compiled: &CompiledTool) -> InvocationContext {
|
||||
let namespace: (String, String, String) = ("notes".into(), "note".into(), "1".into());
|
||||
InvocationContext {
|
||||
workspace: "ws".into(),
|
||||
installation: "installation".into(),
|
||||
plan: "plan".into(),
|
||||
subject: "subject".into(),
|
||||
invocation_id: "not-authority".into(),
|
||||
artifact: compiled.identity().clone(),
|
||||
tool_name: "ticket_test".into(),
|
||||
grants: Grants {
|
||||
ticket_read: true,
|
||||
ticket_data_read: [namespace.clone()].into(),
|
||||
ticket_data_write: [namespace].into(),
|
||||
..Default::default()
|
||||
},
|
||||
deadline: Instant::now() + Duration::from_secs(60),
|
||||
cancellation: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
async fn run(
|
||||
ex: &Executor,
|
||||
compiled: &CompiledTool,
|
||||
owner: Arc<Owner>,
|
||||
mode: &str,
|
||||
granted: bool,
|
||||
) -> Result<InvocationResult, ExecutionError> {
|
||||
let mut ctx = context(compiled);
|
||||
if !granted {
|
||||
ctx.grants.ticket_data_write.clear();
|
||||
}
|
||||
let host = Arc::new(
|
||||
OwnerHost::begin(
|
||||
owner,
|
||||
ctx,
|
||||
PluginConsumer::ToolCall {
|
||||
tool_call_id: "call".into(),
|
||||
tool_name: "ticket_test".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
ex.invoke(
|
||||
compiled,
|
||||
host.context().clone(),
|
||||
host,
|
||||
"ticket_test",
|
||||
&format!("\"{mode}\""),
|
||||
"{}",
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn actual_component_resolves_reads_mutates_lists_and_reconciles_receipts() {
|
||||
let artifact = artifact(include_str!("ticket-guest/plugin.toml"));
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let compiled = ex
|
||||
.compile(
|
||||
&artifact.bytes,
|
||||
&artifact.package_digest,
|
||||
&artifact.artifact_digest,
|
||||
artifact.artifact_size,
|
||||
)
|
||||
.unwrap();
|
||||
let owner = Arc::new(Owner::new(false));
|
||||
let result = run(&ex, &compiled, owner.clone(), "all", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.output.summary, "unchanged title");
|
||||
let s = owner.state.lock().unwrap();
|
||||
assert_eq!(s.receipts.len(), 3); // put/delete/recreate, not a fourth replay
|
||||
assert!(
|
||||
matches!(&s.cell, PluginDataCell::Present { last_write, .. } if last_write == "mutation-recreate")
|
||||
);
|
||||
assert_eq!(
|
||||
s.calls
|
||||
.iter()
|
||||
.filter(|r| matches!(r, PluginHostRequest::DataMutate { .. }))
|
||||
.count(),
|
||||
4
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn actual_component_denies_old_grants_foreign_resources_revocation_and_bad_schema() {
|
||||
let artifact = artifact(include_str!("ticket-guest/plugin.toml"));
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let compiled = ex
|
||||
.compile(
|
||||
&artifact.bytes,
|
||||
&artifact.package_digest,
|
||||
&artifact.artifact_digest,
|
||||
artifact.artifact_size,
|
||||
)
|
||||
.unwrap();
|
||||
for (mode, revoked, granted) in [
|
||||
("read", true, true),
|
||||
("foreign", false, true),
|
||||
("stale", false, true),
|
||||
("other-schema", false, true),
|
||||
("read", false, false),
|
||||
("bad-schema", false, true),
|
||||
] {
|
||||
let owner = Arc::new(Owner::new(revoked));
|
||||
assert!(
|
||||
run(&ex, &compiled, owner.clone(), mode, granted)
|
||||
.await
|
||||
.is_err(),
|
||||
"{mode}"
|
||||
);
|
||||
assert!(
|
||||
!owner
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.calls
|
||||
.iter()
|
||||
.any(|r| matches!(r, PluginHostRequest::DataMutate { .. })),
|
||||
"rejection precedes write"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user