use thiserror::Error;
use crate::definition::{ChoiceOption, Control, ControlKind, InteractionDefinition, Requirement};
use crate::error::ProtocolError;
use crate::ids::{ControlId, InstanceId, OptionId, ResponseId};
use crate::instance::{Audience, InteractionInstance, LifecycleState};
use crate::lifecycle::Lifecycle;
use crate::response::{AssertionKind, ControlValue, Response};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct HandlerId(String);
impl HandlerId {
pub fn new(name: impl Into<String>) -> Result<Self, ProtocolError> {
let name = name.into();
if name.is_empty() {
return Err(ProtocolError::InvalidId {
kind: "handler id",
reason: "must not be empty".to_string(),
});
}
Ok(Self(name))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisteredAction {
pub option: OptionId,
pub handler: HandlerId,
pub audiences: Vec<Audience>,
}
#[derive(Debug, Clone, Copy)]
pub struct ResponderContext<'a> {
pub workspace_key: &'a str,
pub registered: &'a [RegisteredAction],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedAction {
pub control: ControlId,
pub option: OptionId,
pub handler: HandlerId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Accepted {
pub response: ResponseId,
pub instance: InstanceId,
pub actions: Vec<ResolvedAction>,
}
#[derive(Debug, Error)]
pub enum Refusal {
#[error("the offer is {state:?}, not Published; it accepts no response")]
NotPublished {
state: LifecycleState,
},
#[error("the response answers instance `{named}`, but this offer is `{actual}`")]
InstanceMismatch {
named: String,
actual: String,
},
#[error("the response answers definition `{named}`, but the offer binds `{offered}`")]
DefinitionMismatch {
named: String,
offered: String,
},
#[error("definition digest mismatch: the response binds `{bound}`, the presented form is `{presented}`")]
DigestMismatch {
bound: String,
presented: String,
},
#[error("the response is against revision {offered}, but the definition is at {current}")]
StaleRevision {
offered: u64,
current: u64,
},
#[error(
"workspace fence mismatch: the offer is fenced to `{offered}`, the caller is in `{caller}`"
)]
WorkspaceMismatch {
offered: String,
caller: String,
},
#[error("this offer is not open to the {audience:?} audience")]
AudienceMismatch {
audience: Audience,
},
#[error("this offer requires an authenticated assertion")]
AssertionRequired,
#[error("`{control}` is not a control of this definition")]
ExtraControl {
control: String,
},
#[error("`{control}` is answered more than once")]
DuplicateControl {
control: String,
},
#[error("required control `{control}` has no answer")]
MissingRequiredControl {
control: String,
},
#[error("control `{control}` is {expected}, but the value is {found}")]
WrongControlType {
control: String,
expected: &'static str,
found: &'static str,
},
#[error("control `{control}` expects {expected}, but got `{found}`")]
MalformedValue {
control: String,
expected: String,
found: String,
},
#[error("control `{control}` does not offer option `{option}`")]
UnknownOption {
control: String,
option: String,
},
#[error("option `{option}` maps to no registered handler")]
UnknownAction {
option: String,
},
#[error("option `{option}` is not eligible for the {audience:?} audience")]
ActionNotEligible {
option: String,
audience: Audience,
},
#[error(transparent)]
Protocol(#[from] ProtocolError),
}
fn kind_name(kind: &ControlKind) -> &'static str {
match kind {
ControlKind::Choice { .. } => "choice",
ControlKind::Text => "text",
ControlKind::Toggle => "toggle",
ControlKind::Secret => "secret",
ControlKind::Number { .. } => "number",
ControlKind::Range { .. } => "range",
ControlKind::Temporal { .. } => "temporal",
ControlKind::Color => "color",
ControlKind::Path { .. } => "path",
}
}
fn value_name(value: &ControlValue) -> &'static str {
match value {
ControlValue::Choice { .. } => "choice",
ControlValue::Text { .. } => "text",
ControlValue::Toggle { .. } => "toggle",
ControlValue::Secret { .. } => "secret",
}
}
#[must_use]
pub fn resolve_typed(options: &[ChoiceOption], input: &str) -> Option<OptionId> {
let input = input.trim();
let single = |mut matches: std::vec::IntoIter<&ChoiceOption>| -> Option<OptionId> {
let first = matches.next()?;
matches.next().is_none().then(|| first.id.clone())
};
let canonical: Vec<&ChoiceOption> = options
.iter()
.filter(|o| o.id.as_str() == input || o.key == input)
.collect();
if !canonical.is_empty() {
return single(canonical.into_iter());
}
let aliased: Vec<&ChoiceOption> = options
.iter()
.filter(|o| o.aliases.iter().any(|alias| alias == input))
.collect();
single(aliased.into_iter())
}
fn check_value(control: &Control, value: &ControlValue) -> Result<Option<OptionId>, Refusal> {
match (&control.kind, value) {
(ControlKind::Choice { options }, ControlValue::Choice { option }) => {
if options.iter().any(|o| &o.id == option) {
Ok(Some(option.clone()))
} else {
Err(Refusal::UnknownOption {
control: control.id.as_str().to_string(),
option: option.as_str().to_string(),
})
}
}
(ControlKind::Toggle, ControlValue::Toggle { .. })
| (ControlKind::Secret, ControlValue::Secret { .. }) => Ok(None),
(kind, ControlValue::Text { text }) if kind.travels_as_text() => kind
.check_text(text)
.map(|()| None)
.map_err(|expected| Refusal::MalformedValue {
control: control.id.as_str().to_string(),
expected,
found: text.clone(),
}),
_ => Err(Refusal::WrongControlType {
control: control.id.as_str().to_string(),
expected: kind_name(&control.kind),
found: value_name(value),
}),
}
}
fn assertion_satisfied(response: &Response) -> bool {
match response.responder_provenance.kind {
AssertionKind::Unauthenticated => false,
AssertionKind::TerminalOperator | AssertionKind::SignedAssertion => {
response.responder_provenance.assertion.is_some()
}
}
}
pub fn validate_response(
definition: &InteractionDefinition,
instance: &InteractionInstance,
lifecycle: &Lifecycle,
response: &Response,
context: &ResponderContext<'_>,
) -> Result<Accepted, Refusal> {
if lifecycle.state() != LifecycleState::Published {
return Err(Refusal::NotPublished {
state: lifecycle.state(),
});
}
let instance_id = instance.instance_id().map_err(Refusal::Protocol)?;
if response.instance != instance_id {
return Err(Refusal::InstanceMismatch {
named: response.instance.to_string(),
actual: instance_id.to_string(),
});
}
if lifecycle.instance() != &instance_id {
return Err(Refusal::InstanceMismatch {
named: lifecycle.instance().to_string(),
actual: instance_id.to_string(),
});
}
if response.definition != instance.definition {
return Err(Refusal::DefinitionMismatch {
named: response.definition.to_string(),
offered: instance.definition.to_string(),
});
}
let presented = definition.definition_id().map_err(Refusal::Protocol)?;
if response.definition != presented {
return Err(Refusal::DigestMismatch {
bound: response.definition.to_string(),
presented: presented.to_string(),
});
}
if response.revision != definition.revision {
return Err(Refusal::StaleRevision {
offered: response.revision.get(),
current: definition.revision.get(),
});
}
if instance.scope.workspace_key != context.workspace_key {
return Err(Refusal::WorkspaceMismatch {
offered: instance.scope.workspace_key.clone(),
caller: context.workspace_key.to_string(),
});
}
let audience = response.responder_provenance.audience.clone();
if !instance.responder_policy.audiences.contains(&audience) {
return Err(Refusal::AudienceMismatch { audience });
}
if instance.responder_policy.requires_assertion && !assertion_satisfied(response) {
return Err(Refusal::AssertionRequired);
}
let mut answered: Vec<&ControlId> = Vec::new();
let mut actions = Vec::new();
for submission in &response.values {
let Some(control) = definition
.controls
.iter()
.find(|c| c.id == submission.control)
else {
return Err(Refusal::ExtraControl {
control: submission.control.as_str().to_string(),
});
};
if answered.contains(&&submission.control) {
return Err(Refusal::DuplicateControl {
control: submission.control.as_str().to_string(),
});
}
answered.push(&submission.control);
if let Some(option) = check_value(control, &submission.value)? {
let Some(registration) = context.registered.iter().find(|r| r.option == option) else {
return Err(Refusal::UnknownAction {
option: option.as_str().to_string(),
});
};
if !registration.audiences.contains(&audience) {
return Err(Refusal::ActionNotEligible {
option: option.as_str().to_string(),
audience,
});
}
actions.push(ResolvedAction {
control: control.id.clone(),
option,
handler: registration.handler.clone(),
});
}
}
for control in &definition.controls {
if control.requirement == Requirement::Required && !answered.contains(&&control.id) {
return Err(Refusal::MissingRequiredControl {
control: control.id.as_str().to_string(),
});
}
}
Ok(Accepted {
response: response.response_id().map_err(Refusal::Protocol)?,
instance: instance_id,
actions,
})
}
#[cfg(test)]
mod model_fidelity {
use super::*;
#[test]
fn the_modelled_audience_set_is_exactly_the_two_the_tla_covers() {
let modelled = [Audience::Terminal, Audience::Web];
for audience in &modelled {
match audience {
Audience::Terminal | Audience::Web => {}
}
}
assert_eq!(
modelled.len(),
2,
"PromptControls.tla's SingleWinner is binary; a third audience \
needs the model revisited, not just this number bumped"
);
}
}
#[cfg(test)]
mod resolve_typed_tests {
use super::resolve_typed;
use crate::definition::{ChoiceOption, SemanticRole};
use crate::ids::OptionId;
fn option(id: &str, role: SemanticRole, key: &str, aliases: &[&str]) -> ChoiceOption {
ChoiceOption {
id: OptionId::new(id).expect("valid id"),
role,
label: id.to_string(),
key: key.to_string(),
aliases: aliases.iter().map(|a| (*a).to_string()).collect(),
}
}
fn menu() -> Vec<ChoiceOption> {
vec![
option("allow_once", SemanticRole::Allow, "a", &[]),
option("allow_permanent", SemanticRole::Allow, "A", &[]),
option("deny", SemanticRole::Deny, "d", &[]),
option("deny_always", SemanticRole::Deny, "D", &[]),
]
}
fn confirm() -> Vec<ChoiceOption> {
vec![
option("allow_once", SemanticRole::Allow, "y", &["Y"]),
option("deny", SemanticRole::Deny, "n", &["N"]),
]
}
fn resolved(options: &[ChoiceOption], input: &str) -> Option<String> {
resolve_typed(options, input).map(|id| id.as_str().to_string())
}
#[test]
fn lowercase_y_confirms() {
assert_eq!(resolved(&confirm(), "y").as_deref(), Some("allow_once"));
}
#[test]
fn uppercase_y_confirms_via_alias() {
assert_eq!(resolved(&confirm(), "Y").as_deref(), Some("allow_once"));
}
#[test]
fn lower_and_upper_n_deny() {
assert_eq!(resolved(&confirm(), "n").as_deref(), Some("deny"));
assert_eq!(resolved(&confirm(), "N").as_deref(), Some("deny"));
}
#[test]
fn empty_unknown_and_malformed_input_resolve_to_nothing() {
for bad in ["", "q", "yes", "y n", "\u{1b}", "yy"] {
assert_eq!(
resolved(&confirm(), bad),
None,
"input {bad:?} must not resolve"
);
}
}
#[test]
fn surrounding_whitespace_is_trimmed() {
assert_eq!(resolved(&confirm(), " Y ").as_deref(), Some("allow_once"));
assert_eq!(resolved(&confirm(), "\tn\n").as_deref(), Some("deny"));
}
#[test]
fn general_menu_parsing_stays_case_sensitive() {
assert_eq!(resolved(&menu(), "a").as_deref(), Some("allow_once"));
assert_eq!(resolved(&menu(), "A").as_deref(), Some("allow_permanent"));
assert_eq!(resolved(&menu(), "d").as_deref(), Some("deny"));
assert_eq!(resolved(&menu(), "D").as_deref(), Some("deny_always"));
}
#[test]
fn value_wire_name_still_matches() {
assert_eq!(
resolved(&menu(), "allow_permanent").as_deref(),
Some("allow_permanent")
);
}
#[test]
fn an_alias_cannot_select_an_action_absent_from_the_question() {
let deny_only = vec![option("deny", SemanticRole::Deny, "n", &["N"])];
assert_eq!(resolved(&deny_only, "Y"), None);
assert_eq!(resolved(&deny_only, "y"), None);
}
#[test]
fn ambiguity_refuses_rather_than_guessing() {
let dup_key = vec![
option("allow_once", SemanticRole::Allow, "x", &[]),
option("deny", SemanticRole::Deny, "x", &[]),
];
assert_eq!(resolved(&dup_key, "x"), None, "ambiguous key must refuse");
let dup_alias = vec![
option("allow_once", SemanticRole::Allow, "y", &["z"]),
option("deny", SemanticRole::Deny, "n", &["z"]),
];
assert_eq!(
resolved(&dup_alias, "z"),
None,
"ambiguous alias must refuse"
);
assert_eq!(resolved(&dup_alias, "y").as_deref(), Some("allow_once"));
assert_eq!(resolved(&dup_alias, "n").as_deref(), Some("deny"));
}
#[test]
fn a_canonical_match_beats_an_alias() {
let shadowing = vec![
option("allow_once", SemanticRole::Allow, "a", &[]),
option("deny", SemanticRole::Deny, "d", &["a"]),
];
assert_eq!(resolved(&shadowing, "a").as_deref(), Some("allow_once"));
}
#[test]
fn an_alias_never_shadows_another_options_wire_name() {
let shadowing = vec![
option("deny", SemanticRole::Deny, "d", &[]),
option("allow_once", SemanticRole::Allow, "a", &["deny"]),
];
assert_eq!(resolved(&shadowing, "deny").as_deref(), Some("deny"));
}
#[test]
fn key_value_collision_between_options_fails_closed() {
let collide = vec![
option("allow_once", SemanticRole::Allow, "deny", &[]),
option("deny", SemanticRole::Deny, "d", &[]),
];
assert_eq!(
resolved(&collide, "deny"),
None,
"a key-vs-wire-name collision is ambiguous and must refuse"
);
}
#[test]
fn refusal_does_not_consult_the_authored_role() {
let deny_first = vec![
option("deny", SemanticRole::Deny, "d", &[]),
option("allow_once", SemanticRole::Allow, "a", &[]),
];
let allow_first = vec![
option("allow_once", SemanticRole::Allow, "a", &[]),
option("deny", SemanticRole::Deny, "d", &[]),
];
let all_allow = vec![
option("allow_once", SemanticRole::Allow, "a", &[]),
option("deny", SemanticRole::Allow, "d", &[]),
];
for options in [&deny_first, &allow_first, &all_allow] {
assert_eq!(
resolved(options, "nonsense"),
None,
"an unresolvable answer yields NOTHING, not a role-chosen option"
);
}
}
}