mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
broker: make the contract's strictness hold at every layer it claims to
Four bug-pass findings on #6742, all one shape: a rule the contract states in
prose but does not enforce at some layer, so the layer above it decides.
**status/code contradictions.** `Indeterminate` carrying a known pre-dispatch
code passed validation, so `execute` returned Ok for a response contradicting
itself: `unauthenticated` means the host refused before running anything — a
known fate — while `indeterminate` means the fate is unknown. A caller reading
the status would reconcile side effects that provably never happened. We already
rejected the mirror case, `outcome_unknown` under `failed`.
Rather than add the reverse one-off check, the pairing is now a closed table.
`BrokerErrorCode::may_be_failed` and `may_be_indeterminate` are exhaustive
matches with no wildcard, so a new code cannot inherit an answer — it forces a
decision at both ends. `indeterminate` admits only `outcome_unknown` and
`internal`; a host fault mid-execution is the one legitimate "I don't know", and
every other code names a fate the host knows. The rationale lives once, in the
`BrokerErrorCode` docs, and `BrokerResponse::validate` consults the predicates
instead of restating them.
**A read page bounded only by the protocol cap.** `ActionOutcome::validate`
never sees the request, so it could only check `MAX_PAGE_LIMIT`: a host could
answer a one-message read with five hundred, within the cap and still an overrun
of what was asked. `validate_for` now applies the request's own number, the one
place both halves are in scope. An absent `limit` is held to the new
`DEFAULT_PAGE_LIMIT` (100) rather than treated as consent to an unbounded page —
the cap is what a host may ever send, the default is what it may send unasked,
so the two constants are deliberately different numbers.
**A response envelope that was silently non-strict.** `#[serde(flatten)]`
disables `deny_unknown_fields` — serde cannot combine them — and the note saying
so concluded strictness was enforced elsewhere. It was not: the envelope accepted
and discarded an unknown top-level key, and accepted an `error` beside a
succeeded outcome or an `outcome` beside a failure, dropping whichever half it
could not represent. Deserialization now routes through a private strict wire
form with an exact key set per status; serialization is untouched, so the wire
format does not change. The request envelope has the same `flatten` and was
checked for the same hole: it does not have one, because `ActionArgs` is
adjacently tagged and contributes exactly `action` and `args`, leaving
`deny_unknown_fields` in force. That is now pinned by test rather than assumed.
**Events could smuggle members past the schema.** `nostr`'s `Event` deserializer
accepts and discards unknown members, so a genuinely signed event could carry an
extra `secretKey` and parse clean — the no-secret rule stopping at the envelope
instead of reaching inside it. `BrokerMessage` now deserializes through a
`deny_unknown_fields` intermediary with exactly the seven canonical NIP-01
members, and the module docs state that the invariant extends inside event
objects.
The exact-key-set table now covers both envelopes and the error payload, not
just args and outcomes: a key set nobody pins is a key set a field can be added
to. A new test parses raw bytes through a transport, because the strict-envelope
and strict-event guards live in `Deserialize` and the typed test double could
never reach them — it hands back a value that was never on a wire.
Each guard was falsified separately against 5116ca2b3, since one passing guard is
no evidence for the others: reverting the limit check, the strict envelope, the
strict event, collapsing `DEFAULT_PAGE_LIMIT` onto the cap, dropping
`deny_unknown_fields` from the request envelope, and adding a field to the
response envelope each fail a different named test.
Reported by ss-bugs-02 on #6742. Addresses the interface half of #6467.
Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
@@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
channel, content, cursor, event_id, is_false, limit, mentions, optional, required, respond_to,
|
channel, content, cursor, event_id, is_false, limit, mentions, optional, required, respond_to,
|
||||||
validate_slug, Action, PubkeyHex, MAX_ABOUT_CHARS, MAX_EMOJI_CHARS, MAX_NAME_CHARS,
|
validate_slug, Action, PubkeyHex, DEFAULT_PAGE_LIMIT, MAX_ABOUT_CHARS, MAX_EMOJI_CHARS,
|
||||||
MAX_PROMPT_CHARS, MAX_SCALAR_CHARS,
|
MAX_NAME_CHARS, MAX_PROMPT_CHARS, MAX_SCALAR_CHARS,
|
||||||
};
|
};
|
||||||
use crate::SdkError;
|
use crate::SdkError;
|
||||||
|
|
||||||
@@ -49,11 +49,26 @@ pub struct ChannelReadArgs {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cursor: Option<String>,
|
pub cursor: Option<String>,
|
||||||
/// Maximum events to return, capped at [`super::MAX_PAGE_LIMIT`].
|
/// Maximum events to return, capped at [`super::MAX_PAGE_LIMIT`].
|
||||||
|
///
|
||||||
|
/// Absent means [`super::DEFAULT_PAGE_LIMIT`], not "unbounded": see
|
||||||
|
/// [`Self::effective_limit`].
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelReadArgs {
|
impl ChannelReadArgs {
|
||||||
|
/// The page size a response to these arguments is held to.
|
||||||
|
///
|
||||||
|
/// Explicit `limit` when set, otherwise [`super::DEFAULT_PAGE_LIMIT`].
|
||||||
|
/// Omitting a limit asks the host to choose a sensible page, not to send an
|
||||||
|
/// unbounded one, so there is always a number a response can be checked
|
||||||
|
/// against — which is what
|
||||||
|
/// [`crate::broker::BrokerResponse::validate_for`] does.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective_limit(&self) -> u32 {
|
||||||
|
self.limit.unwrap_or(DEFAULT_PAGE_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read a whole channel from the host's default window.
|
/// Read a whole channel from the host's default window.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn channel(channel_id: impl Into<String>) -> Self {
|
pub fn channel(channel_id: impl Into<String>) -> Self {
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ pub const MAX_MENTIONS: usize = 50;
|
|||||||
/// Maximum events a single read may return.
|
/// Maximum events a single read may return.
|
||||||
pub const MAX_PAGE_LIMIT: u32 = 500;
|
pub const MAX_PAGE_LIMIT: u32 = 500;
|
||||||
|
|
||||||
|
/// Events a read returns when the request sets no explicit `limit`.
|
||||||
|
///
|
||||||
|
/// A caller that omits `limit` is not agreeing to an unbounded page, so this is
|
||||||
|
/// the number a response is held to in that case — see
|
||||||
|
/// [`crate::broker::BrokerResponse::validate_for`]. It is deliberately well
|
||||||
|
/// under [`MAX_PAGE_LIMIT`]: the cap is what a host may ever send, this is what
|
||||||
|
/// it may send unasked.
|
||||||
|
pub const DEFAULT_PAGE_LIMIT: u32 = 100;
|
||||||
|
|
||||||
/// Maximum accepted length of a read cursor, in bytes.
|
/// Maximum accepted length of a read cursor, in bytes.
|
||||||
pub const MAX_CURSOR_LEN: usize = 256;
|
pub const MAX_CURSOR_LEN: usize = 256;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,30 @@ use super::{
|
|||||||
channel, cursor, event_id, required, Action, PubkeyHex, MAX_NAME_CHARS, MAX_PAGE_LIMIT,
|
channel, cursor, event_id, required, Action, PubkeyHex, MAX_NAME_CHARS, MAX_PAGE_LIMIT,
|
||||||
};
|
};
|
||||||
use crate::SdkError;
|
use crate::SdkError;
|
||||||
use nostr::Event;
|
use nostr::{Event, EventId, Kind, PublicKey, Tags, Timestamp};
|
||||||
|
|
||||||
|
/// The seven canonical members of a Nostr event object, and nothing else.
|
||||||
|
///
|
||||||
|
/// `nostr`'s own `Event` deserializer accepts and *discards* unknown members, so
|
||||||
|
/// deserializing straight into it would put read results outside the strict-wire
|
||||||
|
/// rule that every other payload in this contract obeys: a host could ship an
|
||||||
|
/// extra `secretKey` member inside an event and nothing would object. Routing
|
||||||
|
/// through a `deny_unknown_fields` intermediary restores the rule at the one
|
||||||
|
/// place the contract does not own the type.
|
||||||
|
///
|
||||||
|
/// Field names are the wire names from NIP-01 (`created_at`, not `createdAt`) —
|
||||||
|
/// this is the event's own encoding, not ours to rename.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct StrictEvent {
|
||||||
|
id: EventId,
|
||||||
|
pubkey: PublicKey,
|
||||||
|
created_at: Timestamp,
|
||||||
|
kind: Kind,
|
||||||
|
tags: Tags,
|
||||||
|
content: String,
|
||||||
|
sig: nostr::secp256k1::schnorr::Signature,
|
||||||
|
}
|
||||||
|
|
||||||
/// One message returned by a read: the signed Nostr event, verbatim.
|
/// One message returned by a read: the signed Nostr event, verbatim.
|
||||||
///
|
///
|
||||||
@@ -27,10 +50,34 @@ use nostr::Event;
|
|||||||
/// The wire form is the event's own JSON, so this adds no envelope of its own.
|
/// The wire form is the event's own JSON, so this adds no envelope of its own.
|
||||||
/// Ancestry and mentions are exposed as derived accessors rather than sibling
|
/// Ancestry and mentions are exposed as derived accessors rather than sibling
|
||||||
/// fields, so there is nothing that can disagree with the signed bytes.
|
/// fields, so there is nothing that can disagree with the signed bytes.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
///
|
||||||
|
/// Deserialization is **strict**, via a private `deny_unknown_fields`
|
||||||
|
/// intermediary: an event object carrying any member beyond the canonical seven
|
||||||
|
/// is rejected rather than quietly trimmed, which is what keeps read results
|
||||||
|
/// inside the same no-secret rule as every other payload here. Serialization is
|
||||||
|
/// the event's own, so the wire form is unchanged.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct BrokerMessage(pub Event);
|
pub struct BrokerMessage(pub Event);
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for BrokerMessage {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let strict = StrictEvent::deserialize(deserializer)?;
|
||||||
|
Ok(Self(Event::new(
|
||||||
|
strict.id,
|
||||||
|
strict.pubkey,
|
||||||
|
strict.created_at,
|
||||||
|
strict.kind,
|
||||||
|
strict.tags,
|
||||||
|
strict.content,
|
||||||
|
strict.sig,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl BrokerMessage {
|
impl BrokerMessage {
|
||||||
/// The signed event.
|
/// The signed event.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ pub enum BrokerTransportError {
|
|||||||
detail: String,
|
detail: String,
|
||||||
},
|
},
|
||||||
/// An envelope arrived but did not validate against the request that was
|
/// An envelope arrived but did not validate against the request that was
|
||||||
/// sent — wrong `requestId`, wrong action, or a malformed outcome.
|
/// sent — wrong `requestId`, wrong action, a malformed outcome, or a status
|
||||||
|
/// contradicting its own error code.
|
||||||
///
|
///
|
||||||
/// Produced by [`ValidatedResponse::validate`], which every
|
/// Produced by [`ValidatedResponse::validate`], which every
|
||||||
/// [`BrokerClientExt::execute`] call runs. A host that answers something
|
/// [`BrokerClientExt::execute`] call runs. A host that answers something
|
||||||
@@ -127,9 +128,10 @@ impl ValidatedResponse {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`BrokerTransportError::MalformedResponse`] when the response
|
/// Returns [`BrokerTransportError::MalformedResponse`] when the response
|
||||||
/// does not correlate, carries an outcome for a different action, or asserts
|
/// does not correlate, carries an outcome for a different action, asserts a
|
||||||
/// a malformed identifier. A response that fails here is not a host verdict
|
/// malformed identifier, or pairs a status with a code that contradicts it.
|
||||||
/// — nothing can be concluded about side effects from it.
|
/// A response that fails here is not a host verdict — nothing can be
|
||||||
|
/// concluded about side effects from it.
|
||||||
pub fn validate(
|
pub fn validate(
|
||||||
response: BrokerResponse,
|
response: BrokerResponse,
|
||||||
request: &PreparedRequest,
|
request: &PreparedRequest,
|
||||||
|
|||||||
@@ -55,6 +55,15 @@
|
|||||||
//! [`AgentsCreateOutcome`] is the case that matters — it returns public identity
|
//! [`AgentsCreateOutcome`] is the case that matters — it returns public identity
|
||||||
//! only, never the key it just minted.
|
//! only, never the key it just minted.
|
||||||
//!
|
//!
|
||||||
|
//! Strictness has to hold at every layer, or the outermost one decides. Two
|
||||||
|
//! places needed explicit work, because each had a hole a derive left open:
|
||||||
|
//! [`BrokerResponse`] cannot combine `deny_unknown_fields` with the `flatten`
|
||||||
|
//! that produces its wire shape, and [`BrokerMessage`] wraps `nostr`'s `Event`,
|
||||||
|
//! whose own deserializer discards unknown members. Both now deserialize through
|
||||||
|
//! private strict intermediaries, so the rule reaches *inside* the envelope and
|
||||||
|
//! inside each event object — a host cannot ship a `secretKey` beside `sig` and
|
||||||
|
//! have it silently trimmed.
|
||||||
|
//!
|
||||||
//! Two limits are worth stating. A `String` field can physically hold secret
|
//! Two limits are worth stating. A `String` field can physically hold secret
|
||||||
//! text, so keeping secrets out of message content and error messages is host
|
//! text, so keeping secrets out of message content and error messages is host
|
||||||
//! policy this contract cannot enforce. And nothing stops a host from *holding*
|
//! policy this contract cannot enforce. And nothing stops a host from *holding*
|
||||||
@@ -323,6 +332,30 @@ impl PreparedRequest {
|
|||||||
/// These name failures the *broker* is responsible for. Failures inside an
|
/// These name failures the *broker* is responsible for. Failures inside an
|
||||||
/// action arrive as [`BrokerErrorCode::ActionFailed`] with detail in the
|
/// action arrive as [`BrokerErrorCode::ActionFailed`] with detail in the
|
||||||
/// message.
|
/// message.
|
||||||
|
///
|
||||||
|
/// # Which status a code may carry
|
||||||
|
///
|
||||||
|
/// A code and a [`BrokerResult`] status are two statements about the same thing
|
||||||
|
/// — whether side effects landed — so they cannot be paired freely. This is the
|
||||||
|
/// whole table, and it lives only here:
|
||||||
|
///
|
||||||
|
/// | Code | with `failed` | with `indeterminate` |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | `outcome_unknown` | no | yes |
|
||||||
|
/// | `internal` | yes | yes |
|
||||||
|
/// | every other code | yes | no |
|
||||||
|
///
|
||||||
|
/// `Failed` promises no side effects took hold; `Indeterminate` promises
|
||||||
|
/// nothing. Every code but two names a fate the host *knows* — a refused
|
||||||
|
/// credential, a rejected envelope, a `requestId` conflict, an action that ran
|
||||||
|
/// and failed — so those are `Failed`-only. [`Self::OutcomeUnknown`] is the code
|
||||||
|
/// for not knowing, so it is `Indeterminate`-only. [`Self::Internal`] is the one
|
||||||
|
/// code that is legitimately either: a host fault before dispatch is a known
|
||||||
|
/// no-op, and the same fault mid-execution genuinely is not.
|
||||||
|
///
|
||||||
|
/// [`Self::may_be_failed`] and [`Self::may_be_indeterminate`] are that table in
|
||||||
|
/// code, consulted by [`BrokerResponse::validate`], which rejects a mismatched
|
||||||
|
/// pairing as malformed rather than trusting either half.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum BrokerErrorCode {
|
pub enum BrokerErrorCode {
|
||||||
@@ -354,9 +387,13 @@ pub enum BrokerErrorCode {
|
|||||||
ActionFailed,
|
ActionFailed,
|
||||||
/// The host could not determine whether side effects occurred.
|
/// The host could not determine whether side effects occurred.
|
||||||
///
|
///
|
||||||
/// Only ever paired with [`BrokerResult::Indeterminate`].
|
/// The only code that is [`BrokerResult::Indeterminate`]-only; see the
|
||||||
|
/// status table on [`BrokerErrorCode`].
|
||||||
OutcomeUnknown,
|
OutcomeUnknown,
|
||||||
/// An unexpected host-side fault.
|
/// An unexpected host-side fault.
|
||||||
|
///
|
||||||
|
/// The only code that may carry either status: a fault before dispatch is a
|
||||||
|
/// known no-op, the same fault mid-execution is not.
|
||||||
Internal,
|
Internal,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +415,49 @@ impl BrokerErrorCode {
|
|||||||
Self::Internal => "internal",
|
Self::Internal => "internal",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this code may appear with [`BrokerResult::Failed`], which
|
||||||
|
/// promises no side effects took hold.
|
||||||
|
///
|
||||||
|
/// One half of the table documented on [`BrokerErrorCode`]. Written as an
|
||||||
|
/// exhaustive match so adding a code forces a decision here rather than
|
||||||
|
/// silently inheriting a default.
|
||||||
|
#[must_use]
|
||||||
|
pub fn may_be_failed(self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::InvalidRequest
|
||||||
|
| Self::UnsupportedProtocolVersion
|
||||||
|
| Self::UnknownAction
|
||||||
|
| Self::UnsupportedActionVersion
|
||||||
|
| Self::Unsupported
|
||||||
|
| Self::Unauthenticated
|
||||||
|
| Self::Unauthorized
|
||||||
|
| Self::RequestIdConflict
|
||||||
|
| Self::ActionFailed
|
||||||
|
| Self::Internal => true,
|
||||||
|
Self::OutcomeUnknown => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this code may appear with [`BrokerResult::Indeterminate`], which
|
||||||
|
/// promises nothing about side effects.
|
||||||
|
///
|
||||||
|
/// The other half of the table documented on [`BrokerErrorCode`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn may_be_indeterminate(self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::OutcomeUnknown | Self::Internal => true,
|
||||||
|
Self::InvalidRequest
|
||||||
|
| Self::UnsupportedProtocolVersion
|
||||||
|
| Self::UnknownAction
|
||||||
|
| Self::UnsupportedActionVersion
|
||||||
|
| Self::Unsupported
|
||||||
|
| Self::Unauthenticated
|
||||||
|
| Self::Unauthorized
|
||||||
|
| Self::RequestIdConflict
|
||||||
|
| Self::ActionFailed => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A broker error: a machine-readable code plus a human-readable message.
|
/// A broker error: a machine-readable code plus a human-readable message.
|
||||||
@@ -425,7 +505,8 @@ impl BrokerError {
|
|||||||
///
|
///
|
||||||
/// [`Self::Indeterminate`] is distinct from [`Self::Failed`] on purpose:
|
/// [`Self::Indeterminate`] is distinct from [`Self::Failed`] on purpose:
|
||||||
/// `Failed` promises no side effects took hold, while `Indeterminate` promises
|
/// `Failed` promises no side effects took hold, while `Indeterminate` promises
|
||||||
/// nothing at all and demands reconciliation.
|
/// nothing at all and demands reconciliation. Which [`BrokerErrorCode`] may
|
||||||
|
/// carry which status is a closed table, documented on that type.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "status", rename_all = "snake_case")]
|
#[serde(tag = "status", rename_all = "snake_case")]
|
||||||
pub enum BrokerResult {
|
pub enum BrokerResult {
|
||||||
@@ -491,10 +572,23 @@ impl BrokerResult {
|
|||||||
/// domain outcome, and is never persisted as part of the stored result. A
|
/// domain outcome, and is never persisted as part of the stored result. A
|
||||||
/// replayed response is byte-identical in `result` to the original.
|
/// replayed response is byte-identical in `result` to the original.
|
||||||
///
|
///
|
||||||
/// Note: this struct cannot use `deny_unknown_fields`, because serde does not
|
/// # Why deserialization goes through an intermediary
|
||||||
/// support combining it with `#[serde(flatten)]`. Strictness is enforced where
|
///
|
||||||
/// it guards execution — on [`BrokerRequest`] and each args type.
|
/// `#[serde(flatten)]` on `result` silently disables `deny_unknown_fields` —
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
/// serde cannot combine the two — so this envelope used to accept and discard an
|
||||||
|
/// unknown top-level key, an unknown key beside `status`, and even an `error`
|
||||||
|
/// riding alongside a succeeded outcome. Every other payload in this contract is
|
||||||
|
/// strict, and a discarded field is exactly how a secret-bearing host field
|
||||||
|
/// crosses a boundary unnoticed.
|
||||||
|
///
|
||||||
|
/// So [`Deserialize`] routes through a private strict wire form with an exact key
|
||||||
|
/// set per status, and anything else fails to parse. A transport reports that as
|
||||||
|
/// [`BrokerTransportError::MalformedResponse`] — the bytes claimed to be an
|
||||||
|
/// envelope and were not one — so the caller gets no verdict instead of a
|
||||||
|
/// quietly trimmed one. Serialization is unchanged, so the wire form is still
|
||||||
|
/// the flattened one; a round-trip test pins that the strict reader accepts what
|
||||||
|
/// the writer emits.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BrokerResponse {
|
pub struct BrokerResponse {
|
||||||
/// Payload discriminator — must equal [`BROKER_RESULT_TYPE`].
|
/// Payload discriminator — must equal [`BROKER_RESULT_TYPE`].
|
||||||
@@ -511,6 +605,99 @@ pub struct BrokerResponse {
|
|||||||
pub replayed: bool,
|
pub replayed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The strict wire form of a [`BrokerResponse`]: every key spelled out, no
|
||||||
|
/// `flatten`, so `deny_unknown_fields` is actually in force.
|
||||||
|
///
|
||||||
|
/// The status-specific members are `Option` here only because one struct has to
|
||||||
|
/// describe three shapes; [`BrokerResponse::deserialize`] then requires the exact
|
||||||
|
/// set for the declared status, so `error` beside a succeeded outcome — or a
|
||||||
|
/// missing `outcome` under `succeeded` — is a parse failure rather than a field
|
||||||
|
/// nobody reads.
|
||||||
|
///
|
||||||
|
/// `outcome` is held as a `serde_json::Value` and re-deserialized under its
|
||||||
|
/// action tag, so this reader is JSON-specific. That is not a narrowing: the HTTP
|
||||||
|
/// binding in [`client`] declares `application/json`, and JSON is the only
|
||||||
|
/// encoding this contract has ever specified.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||||
|
struct WireResponse {
|
||||||
|
r#type: String,
|
||||||
|
protocol_version: u16,
|
||||||
|
request_id: String,
|
||||||
|
status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
action: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
outcome: Option<serde_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
error: Option<BrokerError>,
|
||||||
|
#[serde(default)]
|
||||||
|
replayed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for BrokerResponse {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
use serde::de::Error as _;
|
||||||
|
|
||||||
|
let wire = WireResponse::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
// One arm per status, each naming the members that status may carry.
|
||||||
|
// Anything present that this status does not admit is rejected here, so
|
||||||
|
// "succeeded with an error" cannot be parsed and then ignored.
|
||||||
|
let result = match wire.status.as_str() {
|
||||||
|
"succeeded" => {
|
||||||
|
if wire.error.is_some() {
|
||||||
|
return Err(D::Error::custom(
|
||||||
|
"a succeeded response must not carry an error",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let action = wire
|
||||||
|
.action
|
||||||
|
.ok_or_else(|| D::Error::missing_field("action"))?;
|
||||||
|
let outcome = wire
|
||||||
|
.outcome
|
||||||
|
.ok_or_else(|| D::Error::missing_field("outcome"))?;
|
||||||
|
// Re-deserialize the outcome under its action tag, which is how
|
||||||
|
// the adjacently-tagged `ActionOutcome` — and the
|
||||||
|
// `deny_unknown_fields` on each outcome type — get applied.
|
||||||
|
let tagged = serde_json::json!({ "action": action, "outcome": outcome });
|
||||||
|
let outcome: ActionOutcome =
|
||||||
|
serde_json::from_value(tagged).map_err(D::Error::custom)?;
|
||||||
|
BrokerResult::Succeeded { outcome }
|
||||||
|
}
|
||||||
|
status @ ("failed" | "indeterminate") => {
|
||||||
|
if wire.action.is_some() || wire.outcome.is_some() {
|
||||||
|
return Err(D::Error::custom(format!(
|
||||||
|
"a {status} response must not carry an action or outcome"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let error = wire.error.ok_or_else(|| D::Error::missing_field("error"))?;
|
||||||
|
if status == "failed" {
|
||||||
|
BrokerResult::Failed { error }
|
||||||
|
} else {
|
||||||
|
BrokerResult::Indeterminate { error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(D::Error::custom(format!(
|
||||||
|
"unknown broker result status \"{other}\""
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
r#type: wire.r#type,
|
||||||
|
protocol_version: wire.protocol_version,
|
||||||
|
request_id: wire.request_id,
|
||||||
|
result,
|
||||||
|
replayed: wire.replayed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_false(value: &bool) -> bool {
|
fn is_false(value: &bool) -> bool {
|
||||||
!*value
|
!*value
|
||||||
}
|
}
|
||||||
@@ -562,14 +749,22 @@ impl BrokerResponse {
|
|||||||
validate_request_id(&self.request_id)?;
|
validate_request_id(&self.request_id)?;
|
||||||
match &self.result {
|
match &self.result {
|
||||||
BrokerResult::Succeeded { outcome } => outcome.validate()?,
|
BrokerResult::Succeeded { outcome } => outcome.validate()?,
|
||||||
// `OutcomeUnknown` is the one code that describes not knowing
|
// A code and a status are two statements about whether side effects
|
||||||
// whether side effects landed. Paired with `Failed` — which promises
|
// landed, so a pairing the table forbids is a response
|
||||||
// they did not — it would be self-contradictory, so it is rejected
|
// contradicting itself. Neither half can be trusted over the other,
|
||||||
// rather than left for each caller to notice.
|
// so it is rejected rather than reinterpreted. The table itself
|
||||||
BrokerResult::Failed { error } if error.code == BrokerErrorCode::OutcomeUnknown => {
|
// lives on `BrokerErrorCode`.
|
||||||
return Err(SdkError::InvalidInput(
|
BrokerResult::Failed { error } if !error.code.may_be_failed() => {
|
||||||
"outcome_unknown is only valid with an indeterminate status".into(),
|
return Err(SdkError::InvalidInput(format!(
|
||||||
));
|
"{} is not a valid code for a failed status",
|
||||||
|
error.code.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
BrokerResult::Indeterminate { error } if !error.code.may_be_indeterminate() => {
|
||||||
|
return Err(SdkError::InvalidInput(format!(
|
||||||
|
"{} is not a valid code for an indeterminate status",
|
||||||
|
error.code.as_str()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
BrokerResult::Failed { .. } | BrokerResult::Indeterminate { .. } => {}
|
BrokerResult::Failed { .. } | BrokerResult::Indeterminate { .. } => {}
|
||||||
}
|
}
|
||||||
@@ -595,8 +790,9 @@ impl BrokerResponse {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns everything [`Self::validate`] returns, plus
|
/// Returns everything [`Self::validate`] returns, plus
|
||||||
/// [`SdkError::InvalidInput`] when the `requestId` does not correlate or a
|
/// [`SdkError::InvalidInput`] when the `requestId` does not correlate, a
|
||||||
/// success outcome names a different action than the request.
|
/// success outcome names a different action than the request, or a read
|
||||||
|
/// returned more messages than the request allowed.
|
||||||
pub fn validate_for(&self, request: &PreparedRequest) -> Result<(), SdkError> {
|
pub fn validate_for(&self, request: &PreparedRequest) -> Result<(), SdkError> {
|
||||||
self.validate()?;
|
self.validate()?;
|
||||||
if self.request_id != request.request_id() {
|
if self.request_id != request.request_id() {
|
||||||
@@ -615,6 +811,24 @@ impl BrokerResponse {
|
|||||||
expected.as_str()
|
expected.as_str()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
// `ActionOutcome::validate` can only enforce the protocol-wide cap,
|
||||||
|
// because it never sees the request. A page bounded only by that cap
|
||||||
|
// still overruns a caller that asked for one message and was handed
|
||||||
|
// five hundred, so the request's own number is applied here — the
|
||||||
|
// one place both halves are in scope.
|
||||||
|
if let (
|
||||||
|
ActionArgs::ChannelRead(args),
|
||||||
|
ActionOutcome::ChannelRead(MessagePage { messages, .. }),
|
||||||
|
) = (&request.request.action, outcome)
|
||||||
|
{
|
||||||
|
let allowed = args.effective_limit() as usize;
|
||||||
|
if messages.len() > allowed {
|
||||||
|
return Err(SdkError::InvalidInput(format!(
|
||||||
|
"read returned {} messages for a limit of {allowed}",
|
||||||
|
messages.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ fn signed_message(keys: &Keys) -> BrokerMessage {
|
|||||||
BrokerMessage(event)
|
BrokerMessage(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every [`BrokerErrorCode`] variant, so code-driven tables cannot silently skip
|
||||||
|
/// one: [`error_codes_have_stable_wire_strings`] pins this list against the enum.
|
||||||
|
fn all_error_codes() -> [BrokerErrorCode; 11] {
|
||||||
|
use BrokerErrorCode as E;
|
||||||
|
[
|
||||||
|
E::InvalidRequest,
|
||||||
|
E::UnsupportedProtocolVersion,
|
||||||
|
E::UnknownAction,
|
||||||
|
E::UnsupportedActionVersion,
|
||||||
|
E::Unsupported,
|
||||||
|
E::Unauthenticated,
|
||||||
|
E::Unauthorized,
|
||||||
|
E::RequestIdConflict,
|
||||||
|
E::ActionFailed,
|
||||||
|
E::OutcomeUnknown,
|
||||||
|
E::Internal,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/// One valid `args` value per action, so table-driven tests cannot silently
|
/// One valid `args` value per action, so table-driven tests cannot silently
|
||||||
/// skip an action: [`fixtures_cover_every_action`] pins the coverage.
|
/// skip an action: [`fixtures_cover_every_action`] pins the coverage.
|
||||||
fn action_fixtures() -> Vec<ActionArgs> {
|
fn action_fixtures() -> Vec<ActionArgs> {
|
||||||
@@ -246,6 +265,202 @@ fn an_args_shape_cannot_be_paired_with_another_action_name() {
|
|||||||
assert!(serde_json::from_value::<BrokerRequest>(json).is_err());
|
assert!(serde_json::from_value::<BrokerRequest>(json).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `#[serde(flatten)]` silently disables `deny_unknown_fields`, so the response
|
||||||
|
/// envelope — the one payload here that needs `flatten` for its wire shape — read
|
||||||
|
/// as strict while accepting and discarding extra keys. Every rejection below
|
||||||
|
/// parsed cleanly before the strict intermediary existed.
|
||||||
|
///
|
||||||
|
/// The request envelope has the same `flatten` but *not* the same hole: its
|
||||||
|
/// `ActionArgs` is adjacently tagged, contributing exactly `action` and `args`,
|
||||||
|
/// so `deny_unknown_fields` still applies to the whole set. That is pinned in
|
||||||
|
/// [`a_request_envelope_rejects_anything_outside_its_exact_key_set`] rather than
|
||||||
|
/// assumed.
|
||||||
|
#[test]
|
||||||
|
fn a_response_envelope_rejects_anything_outside_its_exact_key_set() {
|
||||||
|
let succeeded = || {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": BROKER_RESULT_TYPE,
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"requestId": "req-1",
|
||||||
|
"status": "succeeded",
|
||||||
|
"action": "agents.delete",
|
||||||
|
"outcome": { "agentPubkey": PUBKEY, "displayName": "Gone" },
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let failed = || {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": BROKER_RESULT_TYPE,
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"requestId": "req-1",
|
||||||
|
"status": "failed",
|
||||||
|
"error": { "code": "action_failed", "message": "no" },
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assert!(serde_json::from_value::<BrokerResponse>(succeeded()).is_ok());
|
||||||
|
assert!(serde_json::from_value::<BrokerResponse>(failed()).is_ok());
|
||||||
|
|
||||||
|
let mut rejected: Vec<(&str, serde_json::Value)> = Vec::new();
|
||||||
|
|
||||||
|
// An unknown top-level key, including one that reads as key material.
|
||||||
|
for extra in ["hostNote", "secretKey", "credential"] {
|
||||||
|
let mut json = succeeded();
|
||||||
|
json[extra] = serde_json::json!("nsec1deadbeef");
|
||||||
|
rejected.push((extra, json));
|
||||||
|
let mut json = failed();
|
||||||
|
json[extra] = serde_json::json!("nsec1deadbeef");
|
||||||
|
rejected.push((extra, json));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Members the declared status does not admit. Each of these is a
|
||||||
|
// contradiction the type system already forbids in Rust, and the envelope
|
||||||
|
// used to accept it on the wire and drop the half it could not represent.
|
||||||
|
let mut error_beside_success = succeeded();
|
||||||
|
error_beside_success["error"] = serde_json::json!({ "code": "internal", "message": "?" });
|
||||||
|
rejected.push(("error beside a success", error_beside_success));
|
||||||
|
|
||||||
|
let mut outcome_beside_failure = failed();
|
||||||
|
outcome_beside_failure["action"] = serde_json::json!("agents.delete");
|
||||||
|
outcome_beside_failure["outcome"] =
|
||||||
|
serde_json::json!({ "agentPubkey": PUBKEY, "displayName": "Gone" });
|
||||||
|
rejected.push(("outcome beside a failure", outcome_beside_failure));
|
||||||
|
|
||||||
|
let mut outcome_beside_indeterminate = failed();
|
||||||
|
outcome_beside_indeterminate["status"] = serde_json::json!("indeterminate");
|
||||||
|
outcome_beside_indeterminate["error"] =
|
||||||
|
serde_json::json!({ "code": "outcome_unknown", "message": "?" });
|
||||||
|
outcome_beside_indeterminate["action"] = serde_json::json!("agents.delete");
|
||||||
|
outcome_beside_indeterminate["outcome"] =
|
||||||
|
serde_json::json!({ "agentPubkey": PUBKEY, "displayName": "Gone" });
|
||||||
|
rejected.push((
|
||||||
|
"outcome beside an indeterminate",
|
||||||
|
outcome_beside_indeterminate,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Missing the member its status requires.
|
||||||
|
let mut no_outcome = succeeded();
|
||||||
|
no_outcome.as_object_mut().unwrap().remove("outcome");
|
||||||
|
rejected.push(("success with no outcome", no_outcome));
|
||||||
|
let mut no_error = failed();
|
||||||
|
no_error.as_object_mut().unwrap().remove("error");
|
||||||
|
rejected.push(("failure with no error", no_error));
|
||||||
|
|
||||||
|
// An unknown status is not a fourth disposition to ignore.
|
||||||
|
for status in ["succeeded_partially", "pending", "SUCCEEDED", ""] {
|
||||||
|
let mut json = failed();
|
||||||
|
json["status"] = serde_json::json!(status);
|
||||||
|
rejected.push(("unknown status", json));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strictness still reaches inside the outcome.
|
||||||
|
let mut extra_in_outcome = succeeded();
|
||||||
|
extra_in_outcome["outcome"]["secretKey"] = serde_json::json!("nsec1deadbeef");
|
||||||
|
rejected.push(("unknown key inside the outcome", extra_in_outcome));
|
||||||
|
|
||||||
|
let mut extra_in_error = failed();
|
||||||
|
extra_in_error["error"]["secretKey"] = serde_json::json!("nsec1deadbeef");
|
||||||
|
rejected.push(("unknown key inside the error", extra_in_error));
|
||||||
|
|
||||||
|
for (what, json) in rejected {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<BrokerResponse>(json.clone()).is_err(),
|
||||||
|
"{what} must not deserialize: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict deserialization must not have narrowed what the writer emits: the
|
||||||
|
/// wire form is still the flattened one, and the strict reader is its inverse for
|
||||||
|
/// every status, with and without the optional `replayed`.
|
||||||
|
#[test]
|
||||||
|
fn the_strict_reader_accepts_exactly_what_the_writer_emits() {
|
||||||
|
let signer = Keys::generate();
|
||||||
|
let mut results: Vec<BrokerResult> = outcome_fixtures(&signer)
|
||||||
|
.into_iter()
|
||||||
|
.map(BrokerResult::succeeded)
|
||||||
|
.collect();
|
||||||
|
results.push(BrokerResult::failed(BrokerError::new(
|
||||||
|
BrokerErrorCode::ActionFailed,
|
||||||
|
"runtime not installed",
|
||||||
|
)));
|
||||||
|
results.push(BrokerResult::indeterminate(BrokerError::new(
|
||||||
|
BrokerErrorCode::OutcomeUnknown,
|
||||||
|
"host restarted mid-execution",
|
||||||
|
)));
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
for replayed in [false, true] {
|
||||||
|
let response = if replayed {
|
||||||
|
BrokerResponse::new("req-1", result.clone()).replayed()
|
||||||
|
} else {
|
||||||
|
BrokerResponse::new("req-1", result.clone())
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&response).expect("response serializes");
|
||||||
|
let parsed: BrokerResponse = serde_json::from_value(json.clone())
|
||||||
|
.unwrap_or_else(|e| panic!("strict reader rejected our own bytes {json}: {e}"));
|
||||||
|
assert_eq!(parsed, response);
|
||||||
|
assert_eq!(parsed.replayed, replayed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request envelope flattens too, so it was checked for the same hole. It
|
||||||
|
/// does not have one — `ActionArgs` is adjacently tagged and contributes exactly
|
||||||
|
/// `action` and `args`, leaving `deny_unknown_fields` in force — and this pins
|
||||||
|
/// that, so the request side cannot regress into the response side's bug.
|
||||||
|
#[test]
|
||||||
|
fn a_request_envelope_rejects_anything_outside_its_exact_key_set() {
|
||||||
|
let valid = || {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": BROKER_REQUEST_TYPE,
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"requestId": "req-1",
|
||||||
|
"actionVersion": 1,
|
||||||
|
"action": "channel.read",
|
||||||
|
"args": { "channelId": CHANNEL },
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assert!(serde_json::from_value::<BrokerRequest>(valid()).is_ok());
|
||||||
|
|
||||||
|
// Unknown top-level key, beside the flattened discriminator, and inside the
|
||||||
|
// args — all four positions a smuggled field could take.
|
||||||
|
for extra in ["hostNote", "secretKey", "onBehalfOf", "envVars"] {
|
||||||
|
let mut json = valid();
|
||||||
|
json[extra] = serde_json::json!("nsec1deadbeef");
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<BrokerRequest>(json.clone()).is_err(),
|
||||||
|
"a request carrying top-level \"{extra}\" must not deserialize: {json}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut json = valid();
|
||||||
|
json["args"][extra] = serde_json::json!("nsec1deadbeef");
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<BrokerRequest>(json.clone()).is_err(),
|
||||||
|
"a request carrying \"{extra}\" inside args must not deserialize: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second discriminator-shaped key is not a place to hide one either.
|
||||||
|
let mut extra_tag = valid();
|
||||||
|
extra_tag["outcome"] = serde_json::json!({});
|
||||||
|
assert!(serde_json::from_value::<BrokerRequest>(extra_tag).is_err());
|
||||||
|
|
||||||
|
// Missing required members, so the pin cannot pass by accepting anything.
|
||||||
|
for missing in [
|
||||||
|
"type",
|
||||||
|
"protocolVersion",
|
||||||
|
"requestId",
|
||||||
|
"actionVersion",
|
||||||
|
"args",
|
||||||
|
] {
|
||||||
|
let mut json = valid();
|
||||||
|
json.as_object_mut().unwrap().remove(missing);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<BrokerRequest>(json).is_err(),
|
||||||
|
"a request missing \"{missing}\" must not deserialize"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Envelope rejection ──────────────────────────────────────────────────────
|
// ── Envelope rejection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Unknown names must not resolve, and neither must the *mechanism* names this
|
/// Unknown names must not resolve, and neither must the *mechanism* names this
|
||||||
@@ -351,17 +566,70 @@ fn request_id_must_be_present_bounded_and_printable() {
|
|||||||
// ── Wire schemas: the enforceable no-secret invariant ───────────────────────
|
// ── Wire schemas: the enforceable no-secret invariant ───────────────────────
|
||||||
|
|
||||||
/// The exact wire key set of every args and outcome type, with every optional
|
/// The exact wire key set of every args and outcome type, with every optional
|
||||||
/// field populated so nothing escapes the pin by being absent.
|
/// field populated so nothing escapes the pin by being absent — plus the two
|
||||||
|
/// envelopes, whose own key sets are now equally enforceable.
|
||||||
///
|
///
|
||||||
/// This table *is* the no-secret invariant. Combined with
|
/// This table *is* the no-secret invariant. Combined with
|
||||||
/// `deny_unknown_fields`, it means no field — secret-bearing or otherwise — can
|
/// `deny_unknown_fields`, it means no field — secret-bearing or otherwise — can
|
||||||
/// be added to this contract without a reviewer changing a line here. The
|
/// be added to this contract without a reviewer changing a line here. The
|
||||||
/// `agents.create` outcome is the case that matters: public identity only, never
|
/// `agents.create` outcome is the case that matters: public identity only, never
|
||||||
/// the key the host just minted.
|
/// the key the host just minted.
|
||||||
|
///
|
||||||
|
/// The envelopes are here because a key set nobody pins is a key set a field can
|
||||||
|
/// be added to. The response envelope in particular admits a *different* exact
|
||||||
|
/// set per status, which is what its strict deserializer enforces.
|
||||||
#[test]
|
#[test]
|
||||||
fn every_payload_has_an_exact_and_secret_free_wire_schema() {
|
fn every_payload_has_an_exact_and_secret_free_wire_schema() {
|
||||||
let signer = Keys::generate();
|
let signer = Keys::generate();
|
||||||
let expected: Vec<(&str, Vec<&str>)> = vec![
|
let expected: Vec<(&str, Vec<&str>)> = vec![
|
||||||
|
// Envelopes. Every optional member present, so the pin covers the
|
||||||
|
// widest shape each may take.
|
||||||
|
(
|
||||||
|
"request/envelope",
|
||||||
|
vec![
|
||||||
|
"action",
|
||||||
|
"actionVersion",
|
||||||
|
"args",
|
||||||
|
"protocolVersion",
|
||||||
|
"requestId",
|
||||||
|
"type",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"response/envelope/succeeded",
|
||||||
|
vec![
|
||||||
|
"action",
|
||||||
|
"outcome",
|
||||||
|
"protocolVersion",
|
||||||
|
"replayed",
|
||||||
|
"requestId",
|
||||||
|
"status",
|
||||||
|
"type",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"response/envelope/failed",
|
||||||
|
vec![
|
||||||
|
"error",
|
||||||
|
"protocolVersion",
|
||||||
|
"replayed",
|
||||||
|
"requestId",
|
||||||
|
"status",
|
||||||
|
"type",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"response/envelope/indeterminate",
|
||||||
|
vec![
|
||||||
|
"error",
|
||||||
|
"protocolVersion",
|
||||||
|
"replayed",
|
||||||
|
"requestId",
|
||||||
|
"status",
|
||||||
|
"type",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
("error", vec!["code", "message"]),
|
||||||
// Args, fully populated (optional fields present).
|
// Args, fully populated (optional fields present).
|
||||||
(
|
(
|
||||||
"channel.read/args",
|
"channel.read/args",
|
||||||
@@ -437,6 +705,47 @@ fn every_payload_has_an_exact_and_secret_free_wire_schema() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let mut actual: Vec<(String, Vec<String>)> = Vec::new();
|
let mut actual: Vec<(String, Vec<String>)> = Vec::new();
|
||||||
|
// Envelopes first, in the same order as the table above. `replayed` is set
|
||||||
|
// so the widest shape is what gets pinned.
|
||||||
|
let request = BrokerRequest::new(
|
||||||
|
"req-1",
|
||||||
|
ActionArgs::ChannelRead(ChannelReadArgs::channel(CHANNEL)),
|
||||||
|
)
|
||||||
|
.expect("envelope fixture builds");
|
||||||
|
actual.push((
|
||||||
|
"request/envelope".to_string(),
|
||||||
|
keys_of(&serde_json::to_value(&request).expect("request serializes")),
|
||||||
|
));
|
||||||
|
for (name, result) in [
|
||||||
|
(
|
||||||
|
"succeeded",
|
||||||
|
BrokerResult::succeeded(ActionOutcome::AgentsDelete(AgentsDeleteOutcome {
|
||||||
|
agent_pubkey: pubkey(),
|
||||||
|
display_name: "Gone".into(),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"failed",
|
||||||
|
BrokerResult::failed(BrokerError::new(BrokerErrorCode::ActionFailed, "no")),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"indeterminate",
|
||||||
|
BrokerResult::indeterminate(BrokerError::new(BrokerErrorCode::OutcomeUnknown, "?")),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let response = BrokerResponse::new("req-1", result).replayed();
|
||||||
|
actual.push((
|
||||||
|
format!("response/envelope/{name}"),
|
||||||
|
keys_of(&serde_json::to_value(&response).expect("response serializes")),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
actual.push((
|
||||||
|
"error".to_string(),
|
||||||
|
keys_of(
|
||||||
|
&serde_json::to_value(BrokerError::new(BrokerErrorCode::Internal, "?"))
|
||||||
|
.expect("error serializes"),
|
||||||
|
),
|
||||||
|
));
|
||||||
for args in action_fixtures() {
|
for args in action_fixtures() {
|
||||||
let json = serde_json::to_value(&args).expect("args serialize");
|
let json = serde_json::to_value(&args).expect("args serialize");
|
||||||
actual.push((
|
actual.push((
|
||||||
@@ -720,6 +1029,74 @@ fn read_results_are_signed_events_a_keyless_caller_can_verify() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one type here the contract does not own. `nostr`'s `Event` deserializer
|
||||||
|
/// accepts and discards unknown members, so a genuinely signed event could carry
|
||||||
|
/// an extra `secretKey` and parse clean — the no-secret rule stopping at the
|
||||||
|
/// envelope boundary instead of reaching inside it. Deserializing through a
|
||||||
|
/// `deny_unknown_fields` intermediary closes that, and this drives the injection
|
||||||
|
/// on a real signed event so nothing is rejected for a bad signature instead.
|
||||||
|
#[test]
|
||||||
|
fn an_event_object_cannot_smuggle_a_member_past_the_seven_canonical_ones() {
|
||||||
|
let signer = Keys::generate();
|
||||||
|
let message = signed_message(&signer);
|
||||||
|
let wire = serde_json::to_value(&message).expect("event serializes");
|
||||||
|
|
||||||
|
// The baseline: untouched, this same JSON parses and verifies.
|
||||||
|
let parsed: BrokerMessage =
|
||||||
|
serde_json::from_value(wire.clone()).expect("a signed event round-trips");
|
||||||
|
parsed.verify().expect("and still verifies");
|
||||||
|
|
||||||
|
for extra in ["secretKey", "nsec", "seckey", "credential", "hostNote"] {
|
||||||
|
let mut smuggled = wire.clone();
|
||||||
|
smuggled[extra] = serde_json::json!("nsec1deadbeef");
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<BrokerMessage>(smuggled.clone()).is_err(),
|
||||||
|
"an event carrying \"{extra}\" must not deserialize: {smuggled}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And not through the outcome or the envelope either — the rejection has
|
||||||
|
// to hold at every depth a read result travels.
|
||||||
|
let outcome = serde_json::json!({
|
||||||
|
"action": "channel.read",
|
||||||
|
"outcome": { "messages": [smuggled.clone()] },
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<ActionOutcome>(outcome).is_err(),
|
||||||
|
"an outcome holding an event with \"{extra}\" must not deserialize"
|
||||||
|
);
|
||||||
|
let envelope = serde_json::json!({
|
||||||
|
"type": BROKER_RESULT_TYPE,
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"requestId": "req-1",
|
||||||
|
"status": "succeeded",
|
||||||
|
"action": "channel.read",
|
||||||
|
"outcome": { "messages": [smuggled] },
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<BrokerResponse>(envelope).is_err(),
|
||||||
|
"a response holding an event with \"{extra}\" must not deserialize"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropping a canonical member is a parse failure too, not a default.
|
||||||
|
for missing in [
|
||||||
|
"id",
|
||||||
|
"pubkey",
|
||||||
|
"created_at",
|
||||||
|
"kind",
|
||||||
|
"tags",
|
||||||
|
"content",
|
||||||
|
"sig",
|
||||||
|
] {
|
||||||
|
let mut json = wire.clone();
|
||||||
|
json.as_object_mut().unwrap().remove(missing);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<BrokerMessage>(json).is_err(),
|
||||||
|
"an event missing \"{missing}\" must not deserialize"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_page_is_bounded_and_its_cursor_opaque() {
|
fn a_page_is_bounded_and_its_cursor_opaque() {
|
||||||
let signer = Keys::generate();
|
let signer = Keys::generate();
|
||||||
@@ -741,6 +1118,75 @@ fn a_page_is_bounded_and_its_cursor_opaque() {
|
|||||||
.is_err());
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The protocol cap is not the caller's limit. `ActionOutcome::validate` never
|
||||||
|
/// sees the request, so on its own it would let a host answer a one-message read
|
||||||
|
/// with five hundred — within the cap, and still an overrun of what was asked.
|
||||||
|
/// The request's own number is therefore enforced where both halves are in
|
||||||
|
/// scope, and an absent `limit` is held to [`actions::DEFAULT_PAGE_LIMIT`]
|
||||||
|
/// rather than treated as consent to an unbounded page.
|
||||||
|
#[test]
|
||||||
|
fn a_read_page_is_bounded_by_the_limit_its_own_request_asked_for() {
|
||||||
|
let signer = Keys::generate();
|
||||||
|
let page = |count: usize| {
|
||||||
|
BrokerResult::succeeded(ActionOutcome::ChannelRead(MessagePage {
|
||||||
|
messages: vec![signed_message(&signer); count],
|
||||||
|
next_cursor: None,
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Explicit limits, and the absent case — which is the one a host could
|
||||||
|
// otherwise read as "as many as you like".
|
||||||
|
for limit in [Some(1_u32), Some(2), Some(actions::MAX_PAGE_LIMIT), None] {
|
||||||
|
let args = ChannelReadArgs {
|
||||||
|
channel_id: CHANNEL.into(),
|
||||||
|
limit,
|
||||||
|
..ChannelReadArgs::default()
|
||||||
|
};
|
||||||
|
let allowed = limit.unwrap_or(actions::DEFAULT_PAGE_LIMIT) as usize;
|
||||||
|
assert_eq!(
|
||||||
|
args.effective_limit() as usize,
|
||||||
|
allowed,
|
||||||
|
"effective_limit must not diverge from the documented default"
|
||||||
|
);
|
||||||
|
let request = prepared(ActionArgs::ChannelRead(args));
|
||||||
|
|
||||||
|
BrokerResponse::new(request.request_id(), page(allowed))
|
||||||
|
.validate_for(&request)
|
||||||
|
.unwrap_or_else(|e| panic!("a page exactly at a limit of {allowed} is allowed: {e}"));
|
||||||
|
BrokerResponse::new(request.request_id(), page(allowed - 1))
|
||||||
|
.validate_for(&request)
|
||||||
|
.unwrap_or_else(|e| panic!("a short page is allowed: {e}"));
|
||||||
|
|
||||||
|
// One over is rejected — including one over the default, which is the
|
||||||
|
// case an unlimited request would have smuggled through. At the
|
||||||
|
// protocol cap the outcome's own bound fires first, which is a rejection
|
||||||
|
// for a different (and also correct) reason, so only the message below
|
||||||
|
// the cap is pinned to the request's number.
|
||||||
|
let over =
|
||||||
|
BrokerResponse::new(request.request_id(), page(allowed + 1)).validate_for(&request);
|
||||||
|
let error = over.unwrap_err().to_string();
|
||||||
|
if allowed < actions::MAX_PAGE_LIMIT as usize {
|
||||||
|
assert!(
|
||||||
|
error.contains(&format!("limit of {allowed}")),
|
||||||
|
"unexpected error for a limit of {allowed}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default is a real bound, not the cap under another name: a host that
|
||||||
|
// answers an unlimited read with a cap-sized page is still overrunning it.
|
||||||
|
const {
|
||||||
|
assert!(actions::DEFAULT_PAGE_LIMIT < actions::MAX_PAGE_LIMIT);
|
||||||
|
}
|
||||||
|
let unlimited = prepared(ActionArgs::ChannelRead(ChannelReadArgs::channel(CHANNEL)));
|
||||||
|
assert!(BrokerResponse::new(
|
||||||
|
unlimited.request_id(),
|
||||||
|
page(actions::MAX_PAGE_LIMIT as usize)
|
||||||
|
)
|
||||||
|
.validate_for(&unlimited)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
// ── Results ─────────────────────────────────────────────────────────────────
|
// ── Results ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -768,37 +1214,76 @@ fn failed_and_indeterminate_are_distinct_and_carry_no_outcome() {
|
|||||||
assert!(indeterminate.outcome().is_none());
|
assert!(indeterminate.outcome().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A rejected credential is a host verdict with a known fate — the action did
|
/// A code and a status are two statements about the same fact — whether side
|
||||||
/// not run — so it belongs in a `Failed` envelope, not in the transport error
|
/// effects landed — so the contract fixes which pairings are meaningful and
|
||||||
/// type where it would tell the caller to reconcile something that never
|
/// rejects the rest. Driven across every code × both statuses, so adding a code
|
||||||
/// happened. `OutcomeUnknown` is the mirror image: it may only ever be
|
/// forces a decision here.
|
||||||
/// `Indeterminate`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_and_error_code_must_agree_about_side_effects() {
|
fn status_and_error_code_must_agree_about_side_effects() {
|
||||||
let auth_failure = BrokerResponse::new(
|
use BrokerErrorCode as E;
|
||||||
"req-1",
|
for code in all_error_codes() {
|
||||||
BrokerResult::failed(BrokerError::new(
|
let failed =
|
||||||
BrokerErrorCode::Unauthenticated,
|
BrokerResponse::new("req-1", BrokerResult::failed(BrokerError::new(code, "?")))
|
||||||
"credential rejected",
|
.validate();
|
||||||
)),
|
let indeterminate = BrokerResponse::new(
|
||||||
);
|
"req-1",
|
||||||
auth_failure
|
BrokerResult::indeterminate(BrokerError::new(code, "?")),
|
||||||
.validate()
|
)
|
||||||
.expect("a rejected credential is a valid Failed verdict");
|
.validate();
|
||||||
|
|
||||||
let contradiction = BrokerResponse::new(
|
// The table, spelled out independently of the predicates it checks: a
|
||||||
"req-1",
|
// second copy is the point, since a test that asked `may_be_failed()`
|
||||||
BrokerResult::failed(BrokerError::new(BrokerErrorCode::OutcomeUnknown, "?")),
|
// would pass for any implementation of it. Exhaustive with no wildcard,
|
||||||
);
|
// so a new code cannot inherit an answer — it must be decided here too.
|
||||||
let error = contradiction.validate().unwrap_err().to_string();
|
let (failed_ok, indeterminate_ok) = match code {
|
||||||
assert!(error.contains("outcome_unknown"), "unexpected: {error}");
|
E::InvalidRequest
|
||||||
|
| E::UnsupportedProtocolVersion
|
||||||
|
| E::UnknownAction
|
||||||
|
| E::UnsupportedActionVersion
|
||||||
|
| E::Unsupported
|
||||||
|
| E::Unauthenticated
|
||||||
|
| E::Unauthorized
|
||||||
|
| E::RequestIdConflict
|
||||||
|
| E::ActionFailed => (true, false),
|
||||||
|
E::OutcomeUnknown => (false, true),
|
||||||
|
E::Internal => (true, true),
|
||||||
|
};
|
||||||
|
|
||||||
BrokerResponse::new(
|
assert_eq!(
|
||||||
|
failed.is_ok(),
|
||||||
|
failed_ok,
|
||||||
|
"{} with a failed status: {failed:?}",
|
||||||
|
code.as_str()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
indeterminate.is_ok(),
|
||||||
|
indeterminate_ok,
|
||||||
|
"{} with an indeterminate status: {indeterminate:?}",
|
||||||
|
code.as_str()
|
||||||
|
);
|
||||||
|
assert_eq!(code.may_be_failed(), failed_ok);
|
||||||
|
assert_eq!(code.may_be_indeterminate(), indeterminate_ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two directions review found, named: a rejected credential is a
|
||||||
|
// known-fate refusal and cannot claim not to know, and `outcome_unknown`
|
||||||
|
// cannot claim a clean failure.
|
||||||
|
let error = BrokerResponse::new(
|
||||||
"req-1",
|
"req-1",
|
||||||
BrokerResult::indeterminate(BrokerError::new(BrokerErrorCode::OutcomeUnknown, "?")),
|
BrokerResult::indeterminate(BrokerError::new(E::Unauthenticated, "credential rejected")),
|
||||||
)
|
)
|
||||||
.validate()
|
.validate()
|
||||||
.expect("outcome_unknown is valid with indeterminate");
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("unauthenticated"), "unexpected: {error}");
|
||||||
|
let error = BrokerResponse::new(
|
||||||
|
"req-1",
|
||||||
|
BrokerResult::failed(BrokerError::new(E::OutcomeUnknown, "?")),
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("outcome_unknown"), "unexpected: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -948,22 +1433,11 @@ fn preparing_a_request_freezes_the_bytes_every_attempt_sends() {
|
|||||||
/// The hand-written [`BrokerErrorCode::as_str`] and serde's derived name are two
|
/// The hand-written [`BrokerErrorCode::as_str`] and serde's derived name are two
|
||||||
/// encodings of one wire string, so each is pinned against the other and the
|
/// encodings of one wire string, so each is pinned against the other and the
|
||||||
/// whole set is pinned against this literal — a rename in either fails here.
|
/// whole set is pinned against this literal — a rename in either fails here.
|
||||||
|
/// This is also what pins [`all_error_codes`] against the enum: a new variant
|
||||||
|
/// missing from that fixture changes the joined string and fails here.
|
||||||
#[test]
|
#[test]
|
||||||
fn error_codes_have_stable_wire_strings() {
|
fn error_codes_have_stable_wire_strings() {
|
||||||
use BrokerErrorCode as E;
|
let codes = all_error_codes();
|
||||||
let codes = [
|
|
||||||
E::InvalidRequest,
|
|
||||||
E::UnsupportedProtocolVersion,
|
|
||||||
E::UnknownAction,
|
|
||||||
E::UnsupportedActionVersion,
|
|
||||||
E::Unsupported,
|
|
||||||
E::Unauthenticated,
|
|
||||||
E::Unauthorized,
|
|
||||||
E::RequestIdConflict,
|
|
||||||
E::ActionFailed,
|
|
||||||
E::OutcomeUnknown,
|
|
||||||
E::Internal,
|
|
||||||
];
|
|
||||||
for code in codes {
|
for code in codes {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_value(code).unwrap(),
|
serde_json::to_value(code).unwrap(),
|
||||||
@@ -972,7 +1446,7 @@ fn error_codes_have_stable_wire_strings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
codes.map(E::as_str).join(","),
|
codes.map(BrokerErrorCode::as_str).join(","),
|
||||||
"invalid_request,unsupported_protocol_version,unknown_action,\
|
"invalid_request,unsupported_protocol_version,unknown_action,\
|
||||||
unsupported_action_version,unsupported,unauthenticated,unauthorized,\
|
unsupported_action_version,unsupported,unauthenticated,unauthorized,\
|
||||||
request_id_conflict,action_failed,outcome_unknown,internal"
|
request_id_conflict,action_failed,outcome_unknown,internal"
|
||||||
@@ -1122,4 +1596,98 @@ fn a_client_cannot_hand_back_a_response_that_answers_a_different_request() {
|
|||||||
block_on(bad_cursor.execute(&request)).unwrap_err(),
|
block_on(bad_cursor.execute(&request)).unwrap_err(),
|
||||||
BrokerTransportError::MalformedResponse(_)
|
BrokerTransportError::MalformedResponse(_)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// A status contradicting its own code, which is how the review reached this:
|
||||||
|
// `unauthenticated` is a known pre-dispatch refusal, so claiming not to know
|
||||||
|
// the fate is not a verdict `execute` may pass on as `Ok`.
|
||||||
|
let contradictory: Box<dyn BrokerClient> = Box::new(DoubleBroker {
|
||||||
|
response: Ok(BrokerResponse::new(
|
||||||
|
"placeholder",
|
||||||
|
BrokerResult::indeterminate(BrokerError::new(
|
||||||
|
BrokerErrorCode::Unauthenticated,
|
||||||
|
"credential rejected",
|
||||||
|
)),
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
block_on(contradictory.execute(&request)).unwrap_err(),
|
||||||
|
BrokerTransportError::MalformedResponse(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A second double, parsing bytes the way a real HTTP client does, because the
|
||||||
|
/// strict-envelope and strict-event guards live in `Deserialize` and the typed
|
||||||
|
/// double above can never exercise them: it hands back a value that was never on
|
||||||
|
/// a wire.
|
||||||
|
///
|
||||||
|
/// This is the shape the bug actually had — bytes arriving from a host — and what
|
||||||
|
/// the caller sees now is [`BrokerTransportError::MalformedResponse`], not an
|
||||||
|
/// `Ok` whose extra members were quietly dropped.
|
||||||
|
struct WireBroker {
|
||||||
|
body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrokerClient for WireBroker {
|
||||||
|
fn send<'a>(&'a self, _: &'a PreparedRequest, _: Dispatch) -> BrokerFuture<'a> {
|
||||||
|
// Exactly a transport's job: parse an envelope, and report the absence
|
||||||
|
// of one as a transport failure.
|
||||||
|
let parsed = serde_json::from_slice::<BrokerResponse>(&self.body)
|
||||||
|
.map_err(|e| BrokerTransportError::MalformedResponse(e.to_string()));
|
||||||
|
Box::pin(async move { parsed })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bytes_carrying_more_than_the_contract_declares_never_reach_a_caller_as_ok() {
|
||||||
|
let signer = Keys::generate();
|
||||||
|
let request = prepared(ActionArgs::ChannelRead(ChannelReadArgs::channel(CHANNEL)));
|
||||||
|
let event = serde_json::to_value(signed_message(&signer)).expect("event serializes");
|
||||||
|
let envelope = || {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": BROKER_RESULT_TYPE,
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"requestId": request.request_id(),
|
||||||
|
"status": "succeeded",
|
||||||
|
"action": "channel.read",
|
||||||
|
"outcome": { "messages": [event.clone()] },
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// The honest bytes are accepted, so the rejections below are about the
|
||||||
|
// smuggled members and not about this fixture being unparseable.
|
||||||
|
let client = WireBroker {
|
||||||
|
body: serde_json::to_vec(&envelope()).unwrap(),
|
||||||
|
};
|
||||||
|
let response = block_on(client.execute(&request)).expect("honest bytes are an answer");
|
||||||
|
assert!(response.result().outcome().is_some());
|
||||||
|
|
||||||
|
// A key at each depth: on the envelope, inside the outcome, and inside the
|
||||||
|
// signed event — the last being the one `nostr` would have discarded.
|
||||||
|
let mut on_envelope = envelope();
|
||||||
|
on_envelope["secretKey"] = serde_json::json!("nsec1deadbeef");
|
||||||
|
let mut in_outcome = envelope();
|
||||||
|
in_outcome["outcome"]["secretKey"] = serde_json::json!("nsec1deadbeef");
|
||||||
|
let mut in_event = envelope();
|
||||||
|
in_event["outcome"]["messages"][0]["secretKey"] = serde_json::json!("nsec1deadbeef");
|
||||||
|
// And the contradiction the envelope could previously hold on the wire.
|
||||||
|
let mut error_beside_success = envelope();
|
||||||
|
error_beside_success["error"] = serde_json::json!({ "code": "internal", "message": "?" });
|
||||||
|
|
||||||
|
for (what, json) in [
|
||||||
|
("on the envelope", on_envelope),
|
||||||
|
("inside the outcome", in_outcome),
|
||||||
|
("inside the event", in_event),
|
||||||
|
("an error beside a success", error_beside_success),
|
||||||
|
] {
|
||||||
|
let client = WireBroker {
|
||||||
|
body: serde_json::to_vec(&json).unwrap(),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
block_on(client.execute(&request)),
|
||||||
|
Err(BrokerTransportError::MalformedResponse(_))
|
||||||
|
),
|
||||||
|
"{what}: must not reach the caller as Ok"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user