use lgwks_std::json::{Deserialize, Serialize};
use std::any::{Any, TypeId, type_name};
use super::cap::{Auth, Cap};
use super::error::{BotError, Escaped};
use super::gate::GrantSet;
use super::verb::RefreshReason;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct BotSpec {
#[serde(default = "current_spec_version")]
pub version: u32,
pub(crate) name: String,
pub(crate) chains: Vec<ChainSpec>,
}
fn current_spec_version() -> u32 {
BotSpec::CURRENT_VERSION
}
impl BotSpec {
pub const CURRENT_VERSION: u32 = 1;
#[must_use]
pub fn new(name: impl Into<String>, chains: Vec<ChainSpec>) -> Self {
Self {
version: Self::CURRENT_VERSION,
name: name.into(),
chains,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn chains(&self) -> &[ChainSpec] {
&self.chains
}
#[must_use]
pub fn version(&self) -> u32 {
self.version
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct ChainSpec {
pub(crate) source: String,
pub(crate) target: String,
pub(crate) on: Vec<(String, ActionSpec)>,
}
impl ChainSpec {
#[must_use]
pub fn new(
source: impl Into<String>,
target: impl Into<String>,
on: Vec<(String, ActionSpec)>,
) -> Self {
Self {
source: source.into(),
target: target.into(),
on,
}
}
#[must_use]
pub fn source(&self) -> &str {
&self.source
}
#[must_use]
pub fn target(&self) -> &str {
&self.target
}
#[must_use]
pub fn on(&self) -> &[(String, ActionSpec)] {
&self.on
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct ActionSpec {
pub(crate) domain: String,
pub(crate) target: String,
}
impl ActionSpec {
#[must_use]
pub fn new(domain: impl Into<String>, target: impl Into<String>) -> Self {
Self {
domain: domain.into(),
target: target.into(),
}
}
#[must_use]
pub fn domain(&self) -> &str {
&self.domain
}
#[must_use]
pub fn target(&self) -> &str {
&self.target
}
}
pub(crate) fn typed_entry<C, A, T>(condition: C, action: A) -> ChainEntry
where
T: 'static,
C: super::verb::Evaluate<T> + 'static,
A: super::verb::Execute + 'static,
A::Input: 'static,
A::Output: 'static,
{
ChainEntry {
condition: Box::new(TypedEval {
inner: condition,
_marker: std::marker::PhantomData::<T>,
}),
action: Box::new(TypedExec::new(action)),
}
}
pub(crate) struct TypedEval<C, T> {
pub(crate) inner: C,
pub(crate) _marker: std::marker::PhantomData<T>,
}
impl<C: super::verb::Evaluate<T>, T: 'static> EvaluateAny for TypedEval<C, T> {
fn check_any(&self, value: &Erased) -> Result<bool, BotError> {
match value.as_any().downcast_ref::<T>() {
Some(typed) => self.inner.check(typed),
None => Err(BotError::EvaluateError {
cause: format!(
"type mismatch in evaluate — expected {}, got {}",
type_name::<T>(),
value.witness.name(),
),
}),
}
}
}
#[cfg(feature = "ephemeral")]
pub use crate::ecs::EphemeralError;
pub use crate::ecs::{
AbandonReason, EcsBot as Bot, EcsBuilder as BotBuilder, EcsObserveBuilder as ObserveBuilder,
EffectEvidence, EffectScope, PendingWork, RetryPolicy, TransitionHold, WorkId,
};
#[cfg(feature = "ephemeral")]
pub use crate::effect::MintError;
pub use crate::effect::{EffectIdentity, EffectKey};
pub(crate) struct ChainEntry {
pub(crate) condition: Box<dyn EvaluateAny>,
pub(crate) action: Box<dyn ExecuteAny>,
}
impl ChainEntry {
pub(crate) fn erased(condition: Box<dyn EvaluateAny>, action: Box<dyn ExecuteAny>) -> Self {
Self { condition, action }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Witness {
id: TypeId,
name: &'static str,
}
impl Witness {
pub(crate) fn of<T: 'static>() -> Self {
Self {
id: TypeId::of::<T>(),
name: type_name::<T>(),
}
}
pub(crate) fn agrees_with(self, other: Self) -> bool {
self.id == other.id
}
pub(crate) fn name(self) -> &'static str {
self.name
}
}
pub(crate) struct Erased {
pub(crate) value: Box<dyn Any>,
pub(crate) witness: Witness,
}
impl Erased {
pub(crate) fn new<T: 'static>(value: T) -> Self {
Self {
witness: Witness::of::<T>(),
value: Box::new(value),
}
}
pub(crate) fn as_any(&self) -> &dyn Any {
self.value.as_ref()
}
}
pub(crate) trait ObserveAny {
fn domain_id(&self) -> &str;
fn required_caps(&self) -> &[Cap];
fn cache_state(&self) -> Option<RefreshReason>;
fn revision(&self) -> Option<u64>;
fn poll_any<'a>(
&'a self,
grants: &'a GrantSet,
previous: Option<&'a Erased>,
) -> crate::BoxFuture<'a, Result<Option<Erased>, BotError>>;
}
impl<T: super::verb::Observe + 'static> ObserveAny for T
where
T::Output: PartialEq + 'static,
{
fn domain_id(&self) -> &str {
super::verb::Observe::domain_id(self)
}
fn required_caps(&self) -> &[Cap] {
super::verb::Observe::required_caps(self)
}
fn cache_state(&self) -> Option<RefreshReason> {
super::verb::Observe::cache_state(self)
}
fn revision(&self) -> Option<u64> {
super::verb::Observe::revision(self)
}
fn poll_any<'a>(
&'a self,
grants: &'a GrantSet,
previous: Option<&'a Erased>,
) -> crate::BoxFuture<'a, Result<Option<Erased>, BotError>> {
Box::pin(async move {
let auth: Auth = grants.issue(super::verb::Observe::required_caps(self))?;
let value = self.poll((auth, ())).await?;
if let Some(previous) = previous
&& previous
.as_any()
.downcast_ref::<T::Output>()
.is_some_and(|previous| *previous == value)
{
return Ok(None);
}
Ok(Some(Erased::new(value)))
})
}
}
pub(crate) trait EvaluateAny {
fn check_any(&self, value: &Erased) -> Result<bool, BotError>;
}
pub(crate) trait ExecuteAny {
fn required_caps(&self) -> &[Cap];
fn effect_lifetime(&self) -> crate::verb::EffectLifetime;
fn domain_id(&self) -> &str;
fn run_any<'a>(
&'a self,
grants: &'a GrantSet,
input: &'a Erased,
) -> crate::BoxFuture<'a, Result<Box<dyn Any>, BotError>>;
}
pub(crate) struct TypedExec<A>(A);
impl<A> TypedExec<A> {
pub(crate) const fn new(action: A) -> Self {
Self(action)
}
}
impl<A: super::verb::Execute> ExecuteAny for TypedExec<A>
where
A::Input: 'static,
A::Output: 'static,
{
fn required_caps(&self) -> &[Cap] {
self.0.required_caps()
}
fn domain_id(&self) -> &str {
self.0.domain_id()
}
fn effect_lifetime(&self) -> crate::verb::EffectLifetime {
self.0.effect_lifetime()
}
fn run_any<'a>(
&'a self,
grants: &'a GrantSet,
input: &'a Erased,
) -> crate::BoxFuture<'a, Result<Box<dyn Any>, BotError>> {
Box::pin(async move {
match input.as_any().downcast_ref::<A::Input>() {
Some(typed) => {
let auth: Auth = grants.issue(self.0.required_caps())?;
let value = self.0.execute_action((auth, typed)).await?;
let boxed: Box<dyn Any> = Box::new(value);
Ok(boxed)
}
None => Err(BotError::TypeMismatch {
site: "spec::typed_entry",
chain: None,
expected: type_name::<A::Input>(),
observed: input.witness.name(),
}),
}
})
}
}
pub const MAX_SPEC_BYTES: usize = 1024 * 1024;
impl BotSpec {
pub fn to_json(&self) -> Result<String, crate::json::Error> {
crate::json::to_string_pretty(self)
}
pub fn from_json(source: &str) -> Result<Self, BotError> {
if source.len() > MAX_SPEC_BYTES {
let refusal = Err(BotError::SpecTooLarge {
bytes: source.len(),
limit: MAX_SPEC_BYTES,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "from_json: returning an error to the caller");
return refusal;
}
let spec: Self =
crate::json::from_str(source).map_err(|error| BotError::MalformedSpec {
cause: error.to_string().escape_debug().to_string(),
})?;
if spec.version != Self::CURRENT_VERSION {
let refusal = Err(BotError::UnsupportedSpecVersion {
found: spec.version,
supported: Self::CURRENT_VERSION,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "from_json: returning an error to the caller");
return refusal;
}
Ok(spec)
}
}
#[non_exhaustive]
#[derive(Debug)]
pub enum Admission {
Refused(BotError),
Needs(NeedSet),
}
impl std::fmt::Display for Admission {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Self::Refused(ref cause) => write!(formatter, "{cause}"),
Self::Needs(ref needs) => write!(formatter, "admission refused: {needs}"),
}
}
}
impl std::error::Error for Admission {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match *self {
Self::Refused(ref cause) => Some(cause),
Self::Needs(_) => None,
}
}
}
impl From<BotError> for Admission {
fn from(cause: BotError) -> Self {
Self::Refused(cause)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeedSet {
needs: Vec<Need>,
}
impl NeedSet {
#[must_use]
pub fn new(needs: Vec<Need>) -> Self {
Self { needs }
}
#[must_use]
pub fn needs(&self) -> &[Need] {
&self.needs
}
pub fn iter(&self) -> std::slice::Iter<'_, Need> {
self.needs.iter()
}
#[must_use]
pub fn len(&self) -> usize {
self.needs.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.needs.is_empty()
}
#[must_use]
pub fn proposed_grants(&self) -> GrantSet {
let mut grants = GrantSet::empty();
for need in &self.needs {
match *need {
Need::MissingCapability { ref capability, .. } => {
grants = grants.grant(capability.clone());
}
Need::CredentialExpired {
ref capabilities, ..
} => {
for capability in capabilities {
grants = grants.grant(capability.clone());
}
}
_ => {}
}
}
grants
}
#[must_use]
pub fn expired_credentials(domain: &str, capabilities: &[Cap]) -> Self {
let mut sorted = capabilities.to_vec();
sorted.sort_unstable();
sorted.dedup();
Self::new(vec![Need::CredentialExpired {
domain: domain.to_owned(),
capabilities: sorted,
}])
}
}
impl std::fmt::Display for NeedSet {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{} unmet need(s)", self.needs.len())?;
for (index, need) in self.needs.iter().enumerate() {
formatter.write_str(if index == 0 { ": " } else { ", " })?;
write!(formatter, "{need}")?;
}
Ok(())
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Need {
UnknownSource {
chain: usize,
domain: String,
},
UnknownAction {
chain: usize,
action: usize,
domain: String,
},
SourceTargetRejected {
chain: usize,
domain: String,
cause: String,
},
ActionTargetRejected {
chain: usize,
action: usize,
domain: String,
cause: String,
},
UnknownCondition {
chain: usize,
action: usize,
condition: String,
},
CredentialExpired {
domain: String,
capabilities: Vec<Cap>,
},
MissingCapability {
chain: usize,
action: Option<usize>,
domain: String,
capability: Cap,
},
}
impl Need {
#[must_use]
pub fn chain(&self) -> Option<usize> {
match *self {
Self::UnknownSource { chain, .. }
| Self::UnknownAction { chain, .. }
| Self::SourceTargetRejected { chain, .. }
| Self::ActionTargetRejected { chain, .. }
| Self::UnknownCondition { chain, .. }
| Self::MissingCapability { chain, .. } => Some(chain),
Self::CredentialExpired { .. } => None,
}
}
#[must_use]
pub fn action(&self) -> Option<usize> {
match *self {
Self::UnknownAction { action, .. }
| Self::ActionTargetRejected { action, .. }
| Self::UnknownCondition { action, .. } => Some(action),
Self::MissingCapability { action, .. } => action,
Self::UnknownSource { .. }
| Self::SourceTargetRejected { .. }
| Self::CredentialExpired { .. } => None,
}
}
}
impl std::fmt::Display for Need {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Self::UnknownSource { chain, ref domain } => write!(
formatter,
"chain {chain}: unknown source domain {}",
Escaped(domain)
),
Self::UnknownAction {
chain,
action,
ref domain,
} => write!(
formatter,
"chain {chain} action {action}: unknown action domain {}",
Escaped(domain)
),
Self::SourceTargetRejected {
chain,
ref domain,
ref cause,
} => write!(
formatter,
"chain {chain}: source {} refused its target: {}",
Escaped(domain),
Escaped(cause)
),
Self::ActionTargetRejected {
chain,
action,
ref domain,
ref cause,
} => write!(
formatter,
"chain {chain} action {action}: action {} refused its target: {}",
Escaped(domain),
Escaped(cause)
),
Self::CredentialExpired {
ref domain,
ref capabilities,
} => {
let names: Vec<&str> = capabilities.iter().map(|cap| cap.as_str()).collect();
write!(
formatter,
"{}: re-grant [{}], whose credential expired or was refused upstream",
Escaped(domain),
names.join(", ")
)
}
Self::UnknownCondition {
chain,
action,
ref condition,
} => write!(
formatter,
"chain {chain} action {action}: unknown condition {}",
Escaped(condition)
),
Self::MissingCapability {
chain,
action,
ref domain,
ref capability,
} => match action {
Some(action) => write!(
formatter,
"chain {chain} action {action}: {} requires ungranted capability {}",
Escaped(domain),
capability
),
None => write!(
formatter,
"chain {chain}: {} requires ungranted capability {}",
Escaped(domain),
capability
),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::DispatchCertainty;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
type TestResult<T> = Result<T, Box<dyn std::error::Error>>;
fn test_effects() -> TestResult<EffectScope> {
crate::ecs::tests::test_effects()
}
fn failed(cause: impl Into<String>) -> BotError {
BotError::DomainError {
domain: "spec::tests".into(),
certainty: DispatchCertainty::NotDelivered,
cause: cause.into(),
}
}
fn hold_pool_thread_for(duration: std::time::Duration) {
let started = std::time::Instant::now();
while let Some(left) = duration.checked_sub(started.elapsed()) {
if left.is_zero() {
break;
}
std::thread::park_timeout(left);
}
}
struct NetSource {
caps: Vec<Cap>,
value: u32,
}
impl NetSource {
fn net(value: u32) -> Self {
Self {
caps: vec![Cap::net()],
value,
}
}
}
impl crate::verb::Observe for NetSource {
type Output = u32;
fn required_caps(&self) -> &[Cap] {
&self.caps
}
async fn poll(&self, call: (Auth, ())) -> Result<u32, BotError> {
call.0.check(crate::verb::Observe::required_caps(self))?;
Ok(self.value)
}
fn domain_id(&self) -> &str {
"test::net_source"
}
}
struct FakeSource(Vec<Cap>);
impl FakeSource {
fn net() -> Self {
Self(vec![Cap::net()])
}
}
impl crate::verb::Observe for FakeSource {
type Output = u32;
fn required_caps(&self) -> &[Cap] {
&self.0
}
async fn poll(&self, call: (Auth, ())) -> Result<u32, BotError> {
call.0.check(crate::verb::Observe::required_caps(self))?;
Ok(42)
}
fn domain_id(&self) -> &str {
"test::source"
}
}
struct Immediate;
impl crate::verb::Observe for Immediate {
type Output = u32;
fn required_caps(&self) -> &[Cap] {
&[]
}
async fn poll(&self, call: (Auth, ())) -> Result<u32, BotError> {
call.0.check(crate::verb::Observe::required_caps(self))?;
Ok(1)
}
fn domain_id(&self) -> &str {
"test::immediate"
}
}
struct Failing;
impl crate::verb::Observe for Failing {
type Output = u32;
fn required_caps(&self) -> &[Cap] {
&[]
}
async fn poll(&self, _call: (Auth, ())) -> Result<u32, BotError> {
Err(BotError::DomainError {
domain: "test::failing".into(),
certainty: DispatchCertainty::NotDelivered,
cause: "boom".into(),
})
}
fn domain_id(&self) -> &str {
"test::failing"
}
}
struct PeakSource {
in_flight: Arc<AtomicUsize>,
peak: Arc<AtomicUsize>,
}
impl crate::verb::Observe for PeakSource {
type Output = u32;
fn required_caps(&self) -> &[Cap] {
&[]
}
async fn poll(&self, call: (Auth, ())) -> Result<u32, BotError> {
call.0.check(crate::verb::Observe::required_caps(self))?;
let in_flight = Arc::clone(&self.in_flight);
let peak = Arc::clone(&self.peak);
lgwks_std::task::spawn_blocking(move || {
let now = in_flight.fetch_add(1, Ordering::SeqCst).saturating_add(1);
peak.fetch_max(now, Ordering::SeqCst);
hold_pool_thread_for(std::time::Duration::from_millis(40));
in_flight.fetch_sub(1, Ordering::SeqCst);
})
.await;
Ok(1)
}
fn domain_id(&self) -> &str {
"test::peak"
}
}
#[derive(Clone)]
struct Action {
counter: Option<Arc<AtomicUsize>>,
}
impl Action {
fn new() -> Self {
Self { counter: None }
}
fn counting(counter: Arc<AtomicUsize>) -> Self {
Self {
counter: Some(counter),
}
}
}
impl crate::verb::Execute for Action {
type Input = u32;
type Output = ();
fn required_caps(&self) -> &[Cap] {
&[]
}
fn effect_lifetime(&self) -> crate::verb::EffectLifetime {
crate::verb::EffectLifetime::Local
}
async fn execute_action(&self, call: (Auth, &u32)) -> Result<(), BotError> {
call.0.check(crate::verb::Execute::required_caps(self))?;
if let Some(ref counter) = self.counter {
counter.fetch_add(1, Ordering::SeqCst);
}
Ok(())
}
fn domain_id(&self) -> &str {
"test::action"
}
}
#[test]
fn spec_round_trips_json() -> Result<(), BotError> {
let spec = BotSpec {
version: BotSpec::CURRENT_VERSION,
name: "larry".into(),
chains: vec![ChainSpec {
source: "gh::pr_status".into(),
target: "owner/repo".into(),
on: vec![(
"checks_changed".into(),
ActionSpec {
domain: "notify::slack".into(),
target: "#deploys".into(),
},
)],
}],
};
let json = spec
.to_json()
.map_err(|error| failed(format!("a well-formed spec must serialize: {error}")))?;
let back = BotSpec::from_json(&json)?;
assert_eq!(back.name, "larry");
assert_eq!(back.chains.len(), 1);
assert_eq!(back.chains[0].source, "gh::pr_status");
assert_eq!(back.chains[0].on.len(), 1);
assert_eq!(back.chains[0].on[0].0, "checks_changed");
Ok(())
}
#[test]
fn unknown_fields_are_rejected_at_every_level() {
let top = r#"{"name":"x","chains":[],"extra":1}"#;
let chain = r#"{"name":"x","chains":[{"source":"a","target":"b","on":[],"extra":1}]}"#;
let action = r#"{"name":"x","chains":[{"source":"a","target":"b","on":[["c",{"domain":"d","target":"e","extra":1}]]}]}"#;
assert!(
BotSpec::from_json(top).is_err(),
"an unknown top-level field must be rejected, not ignored"
);
assert!(
BotSpec::from_json(chain).is_err(),
"an unknown ChainSpec field must be rejected, not ignored"
);
assert!(
BotSpec::from_json(action).is_err(),
"an unknown ActionSpec field must be rejected, not ignored"
);
}
#[test]
fn missing_required_fields_are_rejected() {
assert!(
BotSpec::from_json(r#"{"chains":[]}"#).is_err(),
"a spec with no name must be rejected, not defaulted"
);
}
#[test]
fn spec_size_bound_is_checked_just_above_the_limit() {
let at = "x".repeat(MAX_SPEC_BYTES);
assert!(matches!(
BotSpec::from_json(&at),
Err(BotError::MalformedSpec { .. })
));
let over = "x".repeat(MAX_SPEC_BYTES + 1);
assert!(matches!(
BotSpec::from_json(&over),
Err(BotError::SpecTooLarge { bytes, limit })
if bytes == MAX_SPEC_BYTES + 1 && limit == MAX_SPEC_BYTES
));
}
#[test]
fn malformed_spec_diagnostic_escapes_control_characters() -> Result<(), BotError> {
let Err(error) = BotSpec::from_json("{\"name\":\"x\",\"chains\":[],\"a\\nb\":1}") else {
return Err(failed(
"a field name containing a raw newline must be rejected as malformed",
));
};
let cause = match error {
BotError::MalformedSpec { cause } => cause,
other => return Err(failed(format!("expected MalformedSpec, got {other:?}"))),
};
assert!(
!cause.contains('\n') && !cause.contains('\r'),
"cause must not carry raw control bytes: {cause:?}"
);
Ok(())
}
#[test]
fn both_builder_entry_points_apply_the_same_admission() -> TestResult<()> {
struct NeedsNet(Vec<Cap>);
impl crate::verb::Observe for NeedsNet {
type Output = u32;
fn required_caps(&self) -> &[Cap] {
&self.0
}
async fn poll(&self, call: (Auth, ())) -> Result<u32, BotError> {
call.0.check(crate::verb::Observe::required_caps(self))?;
Ok(0)
}
fn domain_id(&self) -> &str {
"test::needs_net"
}
}
assert!(
matches!(
Bot::builder("")
.with_effects(test_effects()?)
.build(&GrantSet::empty()),
Err(BotError::IncompleteSpec {
field: "name",
cause: _,
})
),
"the no-chains entry point must reject an empty name"
);
assert!(
matches!(
Bot::builder("")
.observe(NeedsNet(vec![]))
.with_effects(test_effects()?)
.build(&GrantSet::empty()),
Err(BotError::IncompleteSpec {
field: "name",
cause: _,
})
),
"the with-chains entry point must reject the same empty name"
);
let denied = Bot::builder("x")
.observe(NeedsNet(vec![Cap::net()]))
.on(|_: &u32| true, Action::new())
.with_effects(test_effects()?)
.build(&GrantSet::empty());
assert!(
matches!(denied, Err(BotError::CapabilityDenied { .. })),
"a source whose cap is not in the grant set must be denied at build"
);
Ok(())
}
#[test]
fn empty_name_is_rejected() -> TestResult<()> {
let result = Bot::builder("")
.with_effects(test_effects()?)
.build(&GrantSet::all_shipped());
assert!(
result.is_err(),
"an empty name must be rejected even when every cap is granted"
);
Ok(())
}
#[test]
fn capability_denied_without_grant() -> TestResult<()> {
let result = Bot::builder("test")
.observe(FakeSource::net())
.on(|_: &u32| true, Action::new())
.with_effects(test_effects()?)
.build(&GrantSet::empty());
assert!(
result.is_err(),
"`bot.net` must be denied when the grant set is empty"
);
Ok(())
}
#[test]
fn capability_granted_builds_ok() -> TestResult<()> {
let grants = GrantSet::empty().grant(Cap::net());
let bot = Bot::builder("test")
.observe(FakeSource::net())
.on(|_: &u32| true, Action::new())
.with_effects(test_effects()?)
.build(&grants)?;
assert_eq!(bot.name(), "test");
assert_eq!(bot.source_domains().len(), 1);
Ok(())
}
#[test]
fn tick_fires_matching_actions() -> TestResult<()> {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountSource;
impl crate::verb::Observe for CountSource {
type Output = u32;
fn required_caps(&self) -> &[Cap] {
&[]
}
async fn poll(&self, call: (Auth, ())) -> Result<u32, BotError> {
call.0.check(crate::verb::Observe::required_caps(self))?;
Ok(10)
}
fn domain_id(&self) -> &str {
"test::count"
}
}
let counter = Arc::new(AtomicUsize::new(0));
let mut bot = Bot::builder("ticker")
.observe(CountSource)
.on(
|seen: &u32| *seen > 5,
Action::counting(Arc::clone(&counter)),
)
.on(
|seen: &u32| *seen > 100,
Action::counting(Arc::clone(&counter)),
)
.with_effects(test_effects()?)
.build(&GrantSet::empty())?;
let fired = bot.tick()?;
assert_eq!(fired, 1);
assert_eq!(counter.load(Ordering::Relaxed), 1);
Ok(())
}
#[test]
fn issue_denies_what_was_never_granted() -> Result<(), BotError> {
let grants = GrantSet::empty();
match grants.issue(&[Cap::net()]) {
Err(BotError::CapabilityDenied { deficit }) => {
assert_eq!(deficit.first().required(), &Cap::net());
Ok(())
}
other => Err(failed(format!("expected denial, got {other:?}"))),
}
}
#[test]
fn call_with_empty_proof_is_denied_at_the_callee() -> Result<(), BotError> {
use crate::verb::Observe;
let vacuous = GrantSet::empty().issue(&[])?;
match lgwks_std::task::block_on(NetSource::net(1).poll((vacuous, ()))) {
Err(BotError::CapabilityDenied { deficit }) => {
assert_eq!(deficit.first().required(), &Cap::net());
Ok(())
}
other => Err(failed(format!(
"a proof covering nothing must be denied by a capped callee, got {other:?}"
))),
}
}
#[test]
fn wrong_scope_proof_is_denied_confused_deputy() -> Result<(), BotError> {
use crate::verb::Observe;
let fs_only = GrantSet::empty().grant(Cap::fs()).issue(&[Cap::fs()])?;
match lgwks_std::task::block_on(NetSource::net(1).poll((fs_only, ()))) {
Err(BotError::CapabilityDenied { deficit }) => {
assert_eq!(deficit.first().required(), &Cap::net());
Ok(())
}
other => Err(failed(format!(
"a proof scoped to `bot.fs` must not authorize `bot.net`, got {other:?}"
))),
}
}
#[test]
fn issued_proof_authorizes_the_call() -> Result<(), BotError> {
use crate::verb::Observe;
let auth = GrantSet::empty().grant(Cap::net()).issue(&[Cap::net()])?;
assert_eq!(
lgwks_std::task::block_on(NetSource::net(7).poll((auth, ())))?,
7
);
Ok(())
}
#[test]
fn tick_drives_sources_in_one_step() -> TestResult<()> {
let counter = Arc::new(AtomicUsize::new(0));
let mut bot = Bot::builder("direct")
.observe(Immediate)
.on(|_: &u32| true, Action::counting(Arc::clone(&counter)))
.with_effects(test_effects()?)
.build(&GrantSet::empty())?;
let fired = bot.tick()?;
assert_eq!(fired, 1);
assert_eq!(counter.load(Ordering::SeqCst), 1);
Ok(())
}
#[test]
fn tick_polls_sources_concurrently() -> TestResult<()> {
let in_flight = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let source = || PeakSource {
in_flight: Arc::clone(&in_flight),
peak: Arc::clone(&peak),
};
let mut bot = Bot::builder("concurrent")
.observe(source())
.on(
|_: &u32| true,
Action::counting(Arc::new(AtomicUsize::new(0))),
)
.observe(source())
.on(
|_: &u32| true,
Action::counting(Arc::new(AtomicUsize::new(0))),
)
.with_effects(test_effects()?)
.build(&GrantSet::empty())?;
assert_eq!(bot.tick()?, 2);
let observed = peak.load(Ordering::SeqCst);
assert!(
observed >= 2,
"sources overlapped only {observed} at a time"
);
Ok(())
}
#[test]
fn tick_waves_more_chains_than_the_in_flight_cap() -> TestResult<()> {
let counter = Arc::new(AtomicUsize::new(0));
let mut builder = Bot::builder("waves")
.observe(Immediate)
.on(|_: &u32| true, Action::counting(Arc::clone(&counter)));
for _ in 0..39 {
builder = builder
.observe(Immediate)
.on(|_: &u32| true, Action::counting(Arc::clone(&counter)));
}
let mut bot = builder
.with_effects(test_effects()?)
.build(&GrantSet::empty())?;
assert_eq!(bot.source_domains().len(), 40);
assert_eq!(bot.tick()?, 40);
assert_eq!(counter.load(Ordering::SeqCst), 40);
Ok(())
}
#[test]
fn a_failing_poll_fires_nothing_and_returns_the_first_error() -> TestResult<()> {
let counter = Arc::new(AtomicUsize::new(0));
let mut bot = Bot::builder("ordered")
.observe(Immediate)
.on(|_: &u32| true, Action::counting(Arc::clone(&counter)))
.observe(Failing)
.on(|_: &u32| true, Action::counting(Arc::clone(&counter)))
.with_effects(test_effects()?)
.build(&GrantSet::empty())?;
match bot.tick() {
Err(BotError::DomainError { domain, .. }) => assert_eq!(domain, "test::failing"),
other => {
return Err(
failed(format!("expected the failing chain's error, got {other:?}")).into(),
);
}
}
assert_eq!(counter.load(Ordering::SeqCst), 0);
Ok(())
}
}