use std::fmt;
use std::fmt::Write as FmtWrite;
use std::time::Duration;
use super::broker::DispatchError;
use super::cap::Cap;
use super::cap::Deficit;
use super::ecs::{EffectEvidence, PendingWork, WorkId};
use super::effect::{ActionDigest, ActionId, AttemptId, EffectKey};
use super::session::{ResourceAxis, Terminal};
#[derive(Debug)]
#[non_exhaustive]
pub enum BotError {
CapabilityDenied {
deficit: Deficit,
},
IncompleteSpec {
field: &'static str,
cause: String,
},
UnregisteredDomain {
domain: String,
},
DuplicateDomain {
domain: String,
role: &'static str,
first: usize,
second: usize,
},
SpecTooLarge {
bytes: usize,
limit: usize,
},
MalformedSpec {
cause: String,
},
UnsupportedSpecVersion {
found: u32,
supported: u32,
},
UnknownCondition {
condition: String,
argument_parse: String,
},
TickInsideRuntime,
PollStalled {
chain: usize,
deadline: Duration,
},
UnstableObservation {
domain: String,
unstable: crate::stability::Unstable,
},
PollDeadlineUnbounded {
deadline: Duration,
},
PollDeadlineExceeded {
deadline: Duration,
ceiling: Duration,
},
CredentialExpired {
capabilities: Vec<Cap>,
expired_at: Duration,
now: Duration,
},
CredentialRejected {
domain: String,
status: u16,
needs: crate::spec::NeedSet,
},
DomainError {
domain: String,
certainty: DispatchCertainty,
cause: String,
},
EffectIndeterminate {
domain: String,
cause: String,
},
EvaluateError {
cause: String,
},
TypeMismatch {
site: &'static str,
chain: Option<usize>,
expected: &'static str,
observed: &'static str,
},
FlowTooLarge {
bytes: usize,
limit: usize,
},
MalformedFlow {
cause: String,
},
InvalidTransitionTarget {
from: String,
target: String,
},
MissingAskRoute {
node: String,
option: String,
},
VariableNeverWritten {
name: String,
},
VariableReadBeforeInit {
node: String,
name: String,
},
UndeclaredVariable {
name: String,
},
UnreachableNode {
node: String,
},
FlowBudgetExceeded {
steps: usize,
budget: usize,
},
UnknownNodeKind {
node: String,
kind: String,
},
MissingTransition {
node: String,
},
MalformedTemplate {
node: String,
field: &'static str,
},
VariableTypeMismatch {
name: String,
},
AskOptionNotAssignable {
node: String,
variable: String,
option: String,
expected: &'static str,
cause: String,
},
ConflictingTerminalDeclaration {
node: String,
intrinsic: Terminal,
declared: Terminal,
},
UtteranceTooLarge {
bytes: usize,
limit: usize,
},
ValueTooLarge {
variable: String,
bytes: usize,
limit: usize,
},
AskOptionTooLarge {
node: String,
variable: String,
option: String,
bytes: usize,
limit: usize,
},
RecordTooLarge {
node: String,
bytes: usize,
limit: usize,
},
TemplateExpansionTooLarge {
node: String,
bytes: usize,
limit: usize,
},
SessionRetentionExceeded {
bytes: usize,
limit: usize,
},
ResourceLimitAboveCeiling {
axis: ResourceAxis,
requested: usize,
ceiling: usize,
},
PredicateTypeMismatch,
VariableUnset {
name: String,
},
InvalidVariableValue {
variable: String,
value: String,
reason: String,
},
SessionTerminated,
SessionNotAwaitingAnswer,
ResolverReturnedInvalidOption {
node: String,
},
ReceiptNotRecorded {
node: String,
cause: crate::session::JournalError,
},
SessionBudgetExceeded {
steps: usize,
budget: usize,
},
PendingTransition {
work: PendingWork,
outstanding: usize,
},
NoSuchWork {
work: WorkId,
},
ActionNotDeclared {
action: ActionId,
},
EvidenceSuperseded {
work: WorkId,
named: ActionDigest,
current: ActionDigest,
},
EvidenceStaleAttempt {
work: WorkId,
reported: AttemptId,
outstanding: AttemptId,
},
EvidenceContradicted {
work: WorkId,
settled: EffectEvidence,
submitted: EffectEvidence,
},
EffectUnsettled {
action: ActionId,
},
EffectRefused {
cause: DispatchError,
},
EffectUnrecorded {
key: Box<EffectKey>,
evidence: EffectEvidence,
cause: Box<DispatchError>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DispatchCertainty {
Refused,
NotDelivered,
Unsettled,
Occurred,
}
impl DispatchCertainty {
#[must_use]
pub const fn retry_class(self) -> RetryClass {
match self {
Self::Refused | Self::Occurred => RetryClass::Never,
Self::NotDelivered => RetryClass::Safe,
Self::Unsettled => RetryClass::RequiresEvidence,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RetryClass {
Never,
Safe,
RequiresEvidence,
}
impl BotError {
#[must_use]
pub fn dispatch_certainty(&self) -> DispatchCertainty {
match *self {
Self::DomainError { certainty, .. } => certainty,
Self::EffectIndeterminate { .. } => DispatchCertainty::Unsettled,
Self::EffectUnrecorded { evidence, .. } => match evidence {
EffectEvidence::Applied => DispatchCertainty::Occurred,
EffectEvidence::NotApplied => DispatchCertainty::NotDelivered,
},
Self::PollStalled { .. } => DispatchCertainty::NotDelivered,
Self::UnstableObservation { .. } => DispatchCertainty::NotDelivered,
Self::CredentialExpired { .. } | Self::CredentialRejected { .. } => {
DispatchCertainty::Refused
}
_ => DispatchCertainty::Refused,
}
}
#[must_use]
pub fn retry_class(&self) -> RetryClass {
match *self {
Self::EffectUnrecorded { .. } => RetryClass::Never,
_ => self.dispatch_certainty().retry_class(),
}
}
}
impl fmt::Display for Deficit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let count = self.len();
write!(f, "{count} ungranted")?;
for (index, shortage) in self.shortages().enumerate() {
f.write_str(if index == 0 { ": " } else { ", " })?;
write!(f, "{}", Escaped(shortage.required().as_str()))?;
if let Some(demand) = shortage.demand() {
write!(f, " (required by {})", Escaped(demand.domain()))?;
}
}
Ok(())
}
}
impl From<Deficit> for BotError {
fn from(deficit: Deficit) -> Self {
Self::CapabilityDenied { deficit }
}
}
pub(crate) fn unbound_domain<T>(
auth: &super::cap::Auth,
required: &[super::cap::Cap],
domain: &str,
verb: &str,
target: &str,
) -> Result<T, BotError> {
auth.check(required)?;
let refusal = Err(BotError::DomainError {
domain: domain.to_owned(),
certainty: DispatchCertainty::Refused,
cause: format!("{verb} {target:?} — binding required"),
});
lgwks_std::trace::debug!(
error = ?refusal.as_ref().err(),
%domain,
"unbound domain: refusing a call with no bound transport",
);
refusal
}
pub(crate) struct Escaped<'a>(pub(crate) &'a str);
fn write_escaped(formatter: &mut fmt::Formatter<'_>, glyph: char) -> fmt::Result {
match glyph {
'\n' => formatter.write_str("\\n"),
'\r' => formatter.write_str("\\r"),
'\t' => formatter.write_str("\\t"),
control if control.is_control() => write!(formatter, "\\u{{{:x}}}", u32::from(control)),
plain => {
let mut buffer = [0_u8; 4];
formatter.write_str(plain.encode_utf8(&mut buffer))
}
}
}
impl fmt::Display for Escaped<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for glyph in self.0.chars() {
write_escaped(f, glyph)?;
}
Ok(())
}
}
fn write_conflicting_declaration(
formatter: &mut fmt::Formatter<'_>,
node: &str,
intrinsic: &Terminal,
declared: &Terminal,
) -> fmt::Result {
let mut rendered = String::new();
write!(rendered, "terminal declaration on node {} ", Escaped(node))?;
rendered.write_str("contradicts the outcome its kind carries: ")?;
write_terminal_to(&mut rendered, intrinsic)?;
rendered.write_str(" declared as ")?;
write_terminal_to(&mut rendered, declared)?;
formatter.write_str(&rendered)
}
fn write_terminal_to<W: fmt::Write>(sink: &mut W, terminal: &Terminal) -> fmt::Result {
match *terminal {
Terminal::Completed => sink.write_str("completed"),
Terminal::Referred { ref target } => write!(sink, "referred to {target:?}"),
Terminal::HandedOff { ref target } => write!(sink, "handed off to {target:?}"),
Terminal::Refused { ref reason } => write!(sink, "refused because {reason:?}"),
}
}
impl fmt::Display for BotError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::CapabilityDenied { ref deficit } => {
write!(f, "capability denied: {deficit}")
}
Self::IncompleteSpec { field, ref cause } => {
write!(
f,
"incomplete bot spec: missing {field}: {}",
Escaped(cause)
)
}
Self::UnregisteredDomain { ref domain } => {
write!(f, "unregistered domain: {}", Escaped(domain))
}
Self::DuplicateDomain {
ref domain,
role,
first,
second,
} => write!(
f,
"duplicate {role} domain {} declared at positions {first} and {second}",
Escaped(domain)
),
Self::SpecTooLarge { bytes, limit } => {
write!(f, "bot spec is {bytes} bytes, over the {limit}-byte limit")
}
Self::MalformedSpec { ref cause } => {
write!(f, "malformed bot spec: {}", Escaped(cause))
}
Self::UnsupportedSpecVersion { found, supported } => write!(
f,
"bot spec declares version {found}, but this build materializes version \
{supported} only"
),
Self::UnknownCondition {
ref condition,
ref argument_parse,
} => write!(
f,
"unknown condition {}: not in the supported wire vocabulary, or its threshold \
argument did not parse ({})",
Escaped(condition),
Escaped(argument_parse)
),
Self::TickInsideRuntime => f.write_str(
"tick: the synchronous adapter cannot park a thread an async runtime is driving; \
await `tick_async` on that runtime, or call the tick outside it",
),
Self::DomainError {
ref domain,
certainty: _,
ref cause,
} => {
write!(f, "{}: {}", Escaped(domain), Escaped(cause))
}
Self::EffectIndeterminate {
ref domain,
ref cause,
} => {
write!(
f,
"{}: may have taken effect — {}",
Escaped(domain),
Escaped(cause)
)
}
Self::EvaluateError { ref cause } => {
write!(f, "evaluate: {}", Escaped(cause))
}
Self::TypeMismatch {
site,
chain,
expected,
observed,
} => match chain {
Some(index) => write!(
f,
"{site}: type mismatch on chain {index} — expected {expected}, got {observed}"
),
None => write!(
f,
"{site}: type mismatch — expected {expected}, got {observed}"
),
},
Self::FlowTooLarge { bytes, limit } => {
write!(f, "flow is {bytes} bytes, over the {limit}-byte limit")
}
Self::MalformedFlow { ref cause } => {
write!(f, "malformed flow: {}", Escaped(cause))
}
Self::InvalidTransitionTarget {
ref from,
ref target,
} => write!(
f,
"flow transition from {} targets missing node {}",
Escaped(from),
Escaped(target)
),
Self::MissingAskRoute {
ref node,
ref option,
} => write!(
f,
"ask node {} has no route for option {}",
Escaped(node),
Escaped(option)
),
Self::VariableNeverWritten { ref name } => {
write!(f, "declared variable {} is never written", Escaped(name))
}
Self::VariableReadBeforeInit { ref node, ref name } => write!(
f,
"flow node {} reads variable {} before any reaching assignment",
Escaped(node),
Escaped(name)
),
Self::UndeclaredVariable { ref name } => {
write!(f, "flow reads undeclared variable {}", Escaped(name))
}
Self::UnreachableNode { ref node } => {
write!(f, "flow node {} is unreachable", Escaped(node))
}
Self::FlowBudgetExceeded { steps, budget } => {
write!(
f,
"flow declares {steps} nodes over its {budget}-step budget"
)
}
Self::UnknownNodeKind { ref node, ref kind } => {
write!(
f,
"flow node {} has unknown kind {}",
Escaped(node),
Escaped(kind)
)
}
Self::MissingTransition { ref node } => {
write!(f, "flow node {} has no continuation", Escaped(node))
}
Self::MalformedTemplate { ref node, field } => {
write!(
f,
"flow node {} has malformed {field} template",
Escaped(node)
)
}
Self::VariableTypeMismatch { ref name } => {
write!(f, "value for variable {} has the wrong type", Escaped(name))
}
Self::AskOptionNotAssignable {
ref node,
ref variable,
ref option,
expected,
ref cause,
} => write!(
f,
"ask node {} offers option {option:?} for {expected} variable {}, \
which cannot store it: {}",
Escaped(node),
Escaped(variable),
Escaped(cause)
),
Self::ConflictingTerminalDeclaration {
ref node,
ref intrinsic,
ref declared,
} => write_conflicting_declaration(f, node, intrinsic, declared),
Self::UtteranceTooLarge { bytes, limit } => write!(
f,
"answer utterance of {bytes} bytes exceeds the {limit}-byte utterance limit"
),
Self::ValueTooLarge {
ref variable,
bytes,
limit,
} => write!(
f,
"value for variable {} occupies {bytes} bytes, over the {limit}-byte value limit",
Escaped(variable)
),
Self::AskOptionTooLarge {
ref node,
ref variable,
ref option,
bytes,
limit,
} => write!(
f,
"ask node {} offers option {option:?} storing {bytes} bytes for variable {}, \
over the {limit}-byte value limit",
Escaped(node),
Escaped(variable)
),
Self::RecordTooLarge {
ref node,
bytes,
limit,
} => write!(
f,
"record for node {} is {bytes} bytes, over the {limit}-byte record limit",
Escaped(node)
),
Self::TemplateExpansionTooLarge {
ref node,
bytes,
limit,
} => write!(
f,
"template on node {} would expand to {bytes} bytes, over the {limit}-byte \
record limit",
Escaped(node)
),
Self::SessionRetentionExceeded { bytes, limit } => write!(
f,
"session would retain {bytes} bytes, over the {limit}-byte session limit"
),
Self::ResourceLimitAboveCeiling {
axis,
requested,
ceiling,
} => write!(
f,
"{} limit of {requested} bytes exceeds the operator's {ceiling}-byte ceiling",
axis.label()
),
Self::PredicateTypeMismatch => f.write_str("predicate values have incompatible types"),
Self::VariableUnset { ref name } => {
write!(f, "variable {} has no value", Escaped(name))
}
Self::InvalidVariableValue {
ref variable,
ref value,
ref reason,
} => write!(
f,
"value {value:?} cannot be assigned to variable {}: {}",
Escaped(variable),
Escaped(reason)
),
Self::SessionTerminated => f.write_str("session has already terminated"),
Self::SessionNotAwaitingAnswer => f.write_str("session is not awaiting an answer"),
Self::ResolverReturnedInvalidOption { ref node } => {
write!(
f,
"resolver returned an invalid option for node {}",
Escaped(node)
)
}
Self::ReceiptNotRecorded {
ref node,
ref cause,
} => {
write!(
f,
"decision at node {} was not recorded: {}",
Escaped(node),
Escaped(&cause.to_string())
)
}
Self::PollStalled { chain, deadline } => write!(
f,
"source poll for chain {chain} was cancelled at its declared {deadline:?} \
per-poll deadline: nothing was read, nothing committed, and the other chains \
of this tick were not stopped"
),
Self::CredentialExpired {
ref capabilities,
expired_at,
now,
} => {
let names: Vec<&str> = capabilities.iter().map(|cap| cap.as_str()).collect();
write!(
f,
"the credential expired at {expired_at:?} and this call is at {now:?}; it \
covered [{}], and re-granting is the repair",
names.join(", ")
)
}
Self::CredentialRejected {
ref domain,
status,
ref needs,
} => write!(
f,
"{} was refused by its upstream with HTTP {status}: {needs}",
Escaped(domain)
),
Self::UnstableObservation {
ref domain,
unstable,
} => write!(
f,
"source {} read a subject that did not settle ({}); no reading was committed and \
the observation is pending the next tick",
Escaped(domain),
unstable
),
Self::PollDeadlineUnbounded { deadline } => write!(
f,
"per-poll deadline of {deadline:?} bounds no wait: every poll would be \
cancelled before it was first polled"
),
Self::PollDeadlineExceeded { deadline, ceiling } => write!(
f,
"per-poll deadline of {deadline:?} is above the {ceiling:?} ceiling: a source \
that stops answering would hold the tick for longer than any bound this bot \
grants"
),
Self::SessionBudgetExceeded { steps, budget } => {
write!(
f,
"session attempted {steps} steps over its {budget}-step budget"
)
}
Self::PendingTransition {
ref work,
outstanding,
} => write!(
f,
"tick left work unfinished: {} ({outstanding} open)",
Escaped(&work.to_string())
),
Self::NoSuchWork { work } => write!(
f,
"no held effect at chain {} entry {}: only an entry whose outcome is \
indeterminate, or one that was given up on, can be settled with evidence",
work.chain(),
work.entry()
),
Self::ActionNotDeclared { action } => write!(
f,
"action {action} is not declared by this bot: nothing was changed, and \
the key did not come from this bot's pending()"
),
Self::EvidenceSuperseded {
work,
named,
current,
} => write!(
f,
"evidence names binding {named} of chain {} entry {}, which has \
been superseded by binding {current}: nothing was changed, re-read \
pending() and report the key it hands back now",
work.chain(),
work.entry()
),
Self::EvidenceStaleAttempt {
work,
reported,
outstanding,
} => write!(
f,
"evidence was about attempt {} of chain {} entry {}, but that \
entry is on attempt {} now: nothing was changed, and the \
report is about an attempt that is over rather than one outstanding",
reported.get(),
work.chain(),
work.entry(),
outstanding.get()
),
Self::EvidenceContradicted {
work,
settled,
submitted,
} => write!(
f,
"evidence {} contradicts {settled} already recorded for chain {} entry \
{}: nothing was changed, and repeating the same evidence would have \
succeeded",
submitted,
work.chain(),
work.entry()
),
Self::EffectUnsettled { action } => write!(
f,
"no attempt on action {action} was begun: the journal records an \
earlier attempt on it as dispatched with no outcome, so it may \
already be live. Settle it with the key pending() hands back \
before anything is sent again"
),
Self::EffectRefused { ref cause } => {
write!(f, "nothing left the process: {cause}")
}
Self::EffectUnrecorded {
key: _,
evidence,
ref cause,
} => {
write!(
f,
"the effect is {evidence} and only its record is missing: {cause}. \
Retry the journal append, not the action"
)
}
}
}
}
impl std::error::Error for BotError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
None
}
}
#[cfg(test)]
mod tests {
use super::{BotError, DispatchCertainty, Escaped, RetryClass, Terminal};
use crate::cap::{Cap, Deficit, Demand, Shortage};
fn may_have_taken_effect(error: &BotError) -> bool {
matches!(*error, BotError::EffectIndeterminate { .. })
}
#[test]
fn an_indeterminate_effect_is_distinguishable_from_a_domain_error() {
let refused = BotError::DomainError {
domain: String::from("gh::merge"),
certainty: DispatchCertainty::NotDelivered,
cause: String::from("connection closed"),
};
let unknown = BotError::EffectIndeterminate {
domain: String::from("gh::merge"),
cause: String::from("connection closed"),
};
assert!(
!may_have_taken_effect(&refused),
"a domain error is a retry, not a possible duplicate: {refused}"
);
assert!(
may_have_taken_effect(&unknown),
"an indeterminate effect must not be blind-retried: {unknown}"
);
assert_ne!(
refused.to_string(),
unknown.to_string(),
"an operator triages from these lines, so they cannot render alike"
);
}
#[test]
fn the_retry_class_is_total_and_never_reads_a_cause() {
let cases = [
(
BotError::DomainError {
domain: String::from("test::wiring"),
certainty: DispatchCertainty::Refused,
cause: String::from("type mismatch in execute input"),
},
RetryClass::Never,
),
(
BotError::DomainError {
domain: String::from("test::read"),
certainty: DispatchCertainty::NotDelivered,
cause: String::from("connection reset before any byte was sent"),
},
RetryClass::Safe,
),
(
BotError::EffectIndeterminate {
domain: String::from("test::merge"),
cause: String::from("the acknowledgment never arrived"),
},
RetryClass::RequiresEvidence,
),
(BotError::TickInsideRuntime, RetryClass::Never),
];
for (error, expected) in cases {
assert_eq!(
error.retry_class(),
expected,
"the class is what the retry decision reads: {error}"
);
assert_eq!(
error.dispatch_certainty().retry_class(),
expected,
"the two-step path must agree with the one-step path: {error}"
);
}
}
#[test]
fn a_refusal_and_a_wiring_defect_are_both_terminal() {
let binding = BotError::DomainError {
domain: String::from("gh::pr_status"),
certainty: DispatchCertainty::Refused,
cause: String::from("binding required"),
};
let wiring = BotError::DomainError {
domain: String::from("pipeline::step"),
certainty: DispatchCertainty::Refused,
cause: String::from("type mismatch in pipeline step input"),
};
for error in [&binding, &wiring] {
assert_eq!(error.retry_class(), RetryClass::Never);
assert_eq!(error.dispatch_certainty(), DispatchCertainty::Refused);
}
}
#[test]
fn an_indeterminate_effect_names_its_domain_and_the_indeterminacy() {
let error = BotError::EffectIndeterminate {
domain: String::from("notify::slack"),
cause: String::from("request timed out"),
};
let rendered = error.to_string();
assert!(
rendered.contains("notify::slack"),
"the line must name the domain that failed: {rendered}"
);
assert!(
rendered.contains("request timed out"),
"the line must carry the cause: {rendered}"
);
assert!(
rendered.contains("may have taken effect"),
"the failure alone is not enough — the line has to say the effect may be \
live, because that is what makes a retry unsafe: {rendered}"
);
}
const HOSTILE: &str = "a\nb\rc\td\u{1b}[2J e\"f\\g ünïcode 日本";
fn hostile_arms() -> Vec<BotError> {
let payload = String::from(HOSTILE);
vec![
BotError::CapabilityDenied {
deficit: Deficit::new(
Shortage::new(
Cap::new(payload.clone()),
Some(Demand::new(payload.clone())),
),
Vec::new(),
),
},
BotError::MalformedSpec {
cause: payload.clone(),
},
BotError::IncompleteSpec {
field: "target",
cause: payload.clone(),
},
BotError::UnknownCondition {
condition: payload.clone(),
argument_parse: payload.clone(),
},
BotError::DomainError {
domain: payload.clone(),
certainty: DispatchCertainty::Refused,
cause: payload.clone(),
},
BotError::EffectIndeterminate {
domain: payload.clone(),
cause: payload.clone(),
},
BotError::EvaluateError {
cause: payload.clone(),
},
BotError::MalformedFlow {
cause: payload.clone(),
},
BotError::InvalidTransitionTarget {
from: payload.clone(),
target: payload.clone(),
},
BotError::MissingAskRoute {
node: payload.clone(),
option: payload.clone(),
},
BotError::VariableNeverWritten {
name: payload.clone(),
},
BotError::VariableReadBeforeInit {
node: payload.clone(),
name: payload.clone(),
},
BotError::UndeclaredVariable {
name: payload.clone(),
},
BotError::UnreachableNode {
node: payload.clone(),
},
BotError::UnknownNodeKind {
node: payload.clone(),
kind: payload.clone(),
},
BotError::MissingTransition {
node: payload.clone(),
},
BotError::MalformedTemplate {
node: payload.clone(),
field: "label",
},
BotError::VariableTypeMismatch {
name: payload.clone(),
},
BotError::ConflictingTerminalDeclaration {
node: payload.clone(),
intrinsic: Terminal::HandedOff {
target: payload.clone(),
},
declared: Terminal::Refused {
reason: payload.clone(),
},
},
BotError::AskOptionNotAssignable {
node: payload.clone(),
variable: payload.clone(),
option: payload.clone(),
expected: "boolean",
cause: payload.clone(),
},
BotError::VariableUnset {
name: payload.clone(),
},
BotError::InvalidVariableValue {
variable: payload.clone(),
value: payload.clone(),
reason: payload.clone(),
},
BotError::ResolverReturnedInvalidOption { node: payload },
]
}
#[test]
fn a_rendered_payload_cannot_forge_a_log_record() {
let error = BotError::EffectIndeterminate {
domain: String::from(HOSTILE),
cause: String::from(HOSTILE),
};
let rendered = error.to_string();
assert!(
!rendered.contains('\n') && !rendered.contains('\r'),
"a payload must not add a physical log record: {rendered:?}"
);
assert!(
!rendered.chars().any(char::is_control),
"a payload must not carry a live terminal control: {rendered:?}"
);
assert!(
rendered.contains("\\n") && rendered.contains("\\u{1b}"),
"the escapes must be visible rather than stripped, because the payload \
is the diagnostic: {rendered}"
);
}
#[test]
fn every_untrusted_field_is_escaped() {
for error in hostile_arms() {
let rendered = error.to_string();
assert!(
!rendered.chars().any(char::is_control),
"a variant rendered a live control character: {rendered:?}"
);
assert!(
!rendered.contains('\n'),
"a variant rendered a second physical line: {rendered:?}"
);
}
}
#[test]
fn ordinary_diagnostics_keep_their_meaning() {
let error = BotError::DomainError {
domain: String::from("gh::merge"),
certainty: DispatchCertainty::NotDelivered,
cause: String::from("PR #7 — \"timeout\" after 30s, ünïcode 日本 ok"),
};
let rendered = error.to_string();
assert!(
rendered.contains("PR #7 — \"timeout\" after 30s, ünïcode 日本 ok"),
"an ordinary message must survive unaltered: {rendered}"
);
assert!(
rendered.starts_with("gh::merge: "),
"the typed boundary stays readable: {rendered}"
);
}
#[test]
fn escaping_is_idempotent_with_constructor_level_escaping() {
let already_escaped = Escaped("line one\\nline two").to_string();
assert_eq!(
already_escaped, "line one\\nline two",
"text that is already escaped must pass through untouched"
);
let raw = Escaped("line one\nline two").to_string();
assert_eq!(
raw, "line one\\nline two",
"and the escape it produces must be the same shape, so the two agree"
);
}
}