newt_interaction/binding.rs
1//! **Fail-closed response validation** (A3, #1837).
2//!
3//! Pure rules over types this crate already owns — the same shape as
4//! [`plan_presentation`](crate::plan_presentation), and for the same
5//! reason: none of it touches SQLite, a TTY, or a socket, so none of it
6//! belongs in a crate that does. Keeping the security-relevant logic in
7//! the dependency-minimal layer is what makes it auditable.
8//!
9//! **Validation is ATOMIC.** A response is accepted as a whole or refused
10//! as a whole. There is no partial acceptance and no "apply the controls
11//! that checked out" path, because a caller handed half an answer would
12//! have to decide what the other half meant — which is exactly the
13//! per-surface drift this epic exists to end. It is also what keeps the
14//! Lean `authorize` / `hidden_action_rejected` generalization honest: a
15//! hidden or unoffered control cannot ride along beside a valid one.
16//!
17//! **Actions map only to caller-registered handlers.** Nothing in a
18//! definition is executable. A definition carries markdown, labels, and
19//! option ids authored by — possibly — untrusted markup, and this module
20//! never turns any of it into a command, a URL, a tool name, a path, a
21//! topic, or a caveat. The accepted outcome names a
22//! [`HandlerId`](crate::binding::HandlerId) the CALLER registered, and
23//! nothing else. A registry owned down here would be a second capability
24//! system growing beside the store (epic law 13); authority-minting stays
25//! above this layer.
26
27use thiserror::Error;
28
29use crate::definition::{ChoiceOption, Control, ControlKind, InteractionDefinition, Requirement};
30use crate::error::ProtocolError;
31use crate::ids::{ControlId, InstanceId, OptionId, ResponseId};
32use crate::instance::{Audience, InteractionInstance, LifecycleState};
33use crate::lifecycle::Lifecycle;
34use crate::response::{AssertionKind, ControlValue, Response};
35
36/// A caller-supplied handler name.
37///
38/// Opaque to this crate: it routes to something the CALLER holds. This
39/// layer never dereferences it, never interprets it, and never derives
40/// one from a definition.
41#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
42pub struct HandlerId(String);
43
44impl HandlerId {
45 /// Adopt a handler name.
46 ///
47 /// # Errors
48 ///
49 /// [`ProtocolError::InvalidId`] when empty — a handler that names
50 /// nothing cannot be routed to.
51 pub fn new(name: impl Into<String>) -> Result<Self, ProtocolError> {
52 let name = name.into();
53 if name.is_empty() {
54 return Err(ProtocolError::InvalidId {
55 kind: "handler id",
56 reason: "must not be empty".to_string(),
57 });
58 }
59 Ok(Self(name))
60 }
61
62 /// The name as adopted.
63 #[must_use]
64 pub fn as_str(&self) -> &str {
65 &self.0
66 }
67}
68
69/// One action the CALLER has registered, and who may invoke it.
70///
71/// The caller injects these. A definition cannot add one, which is the
72/// property that keeps authored markup from naming an executable thing.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct RegisteredAction {
75 /// The option this action answers to.
76 pub option: OptionId,
77 /// What the caller will run.
78 pub handler: HandlerId,
79 /// Which audiences may invoke it. An audience the offer admits is not
80 /// automatically an audience every ACTION admits.
81 pub audiences: Vec<Audience>,
82}
83
84/// What the caller brings to a validation: its fence and its handlers.
85#[derive(Debug, Clone, Copy)]
86pub struct ResponderContext<'a> {
87 /// The workspace fence the caller is operating in.
88 pub workspace_key: &'a str,
89 /// The actions the caller has registered.
90 pub registered: &'a [RegisteredAction],
91}
92
93/// One accepted submission, resolved to the handler the caller registered.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ResolvedAction {
96 /// Which control was answered.
97 pub control: ControlId,
98 /// Which option was chosen.
99 pub option: OptionId,
100 /// The CALLER's handler. Never derived from the definition.
101 pub handler: HandlerId,
102}
103
104/// A response that passed every rule, with its actions resolved.
105///
106/// Carries ids and caller-registered handler names only. Deliberately no
107/// markdown, label, note, or any other author-supplied string: an
108/// outcome that carried document text would invite a caller to act on it.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Accepted {
111 /// The response that was accepted.
112 pub response: ResponseId,
113 /// The offer it resolves.
114 pub instance: InstanceId,
115 /// The actions to run, in submission order.
116 pub actions: Vec<ResolvedAction>,
117}
118
119/// Why a response was refused. Every variant is a REFUSAL: there is no
120/// variant meaning "accepted with reservations".
121#[derive(Debug, Error)]
122pub enum Refusal {
123 /// The offer is not open for answers.
124 #[error("the offer is {state:?}, not Published; it accepts no response")]
125 NotPublished {
126 /// The state the offer is actually in.
127 state: LifecycleState,
128 },
129 /// The response answers a different offer.
130 #[error("the response answers instance `{named}`, but this offer is `{actual}`")]
131 InstanceMismatch {
132 /// What the response named.
133 named: String,
134 /// The offer being resolved.
135 actual: String,
136 },
137 /// The response answers a different definition than the offer binds.
138 #[error("the response answers definition `{named}`, but the offer binds `{offered}`")]
139 DefinitionMismatch {
140 /// What the response named.
141 named: String,
142 /// What the offer binds.
143 offered: String,
144 },
145 /// The definition presented is not the one the response was minted
146 /// against — it changed by at least one byte.
147 #[error("definition digest mismatch: the response binds `{bound}`, the presented form is `{presented}`")]
148 DigestMismatch {
149 /// The digest the response binds.
150 bound: String,
151 /// The digest of the definition actually presented.
152 presented: String,
153 },
154 /// The responder answered an older revision.
155 #[error("the response is against revision {offered}, but the definition is at {current}")]
156 StaleRevision {
157 /// The revision the responder saw.
158 offered: u64,
159 /// The revision the definition is at now.
160 current: u64,
161 },
162 /// The offer belongs to a different workspace fence.
163 #[error(
164 "workspace fence mismatch: the offer is fenced to `{offered}`, the caller is in `{caller}`"
165 )]
166 WorkspaceMismatch {
167 /// The fence on the offer.
168 offered: String,
169 /// The caller's fence.
170 caller: String,
171 },
172 /// The responder answered from an audience the offer is not open to.
173 #[error("this offer is not open to the {audience:?} audience")]
174 AudienceMismatch {
175 /// The audience that answered.
176 audience: Audience,
177 },
178 /// The offer requires an authenticated assertion and none was
179 /// presented.
180 #[error("this offer requires an authenticated assertion")]
181 AssertionRequired,
182 /// The response answers a control the definition does not offer.
183 #[error("`{control}` is not a control of this definition")]
184 ExtraControl {
185 /// The control the response named.
186 control: String,
187 },
188 /// The response answers one control twice.
189 #[error("`{control}` is answered more than once")]
190 DuplicateControl {
191 /// The control answered twice.
192 control: String,
193 },
194 /// A REQUIRED control has no answer.
195 #[error("required control `{control}` has no answer")]
196 MissingRequiredControl {
197 /// The unanswered control.
198 control: String,
199 },
200 /// The value's kind does not match the control's kind.
201 #[error("control `{control}` is {expected}, but the value is {found}")]
202 WrongControlType {
203 /// The control answered.
204 control: String,
205 /// The kind the definition declares.
206 expected: &'static str,
207 /// The kind the value carries.
208 found: &'static str,
209 },
210 /// The value is the control's kind, but not a well-formed one of it —
211 /// `not-a-date` for a date, `11` for a number capped at 10.
212 ///
213 /// Distinct from [`WrongControlType`](Self::WrongControlType) on purpose:
214 /// that one is a responder answering the wrong FIELD SHAPE, which is a
215 /// protocol error, while this is a human typing something that is not what
216 /// was asked for. A surface can correct the second inline and can do
217 /// nothing about the first.
218 #[error("control `{control}` expects {expected}, but got `{found}`")]
219 MalformedValue {
220 /// The control answered.
221 control: String,
222 /// What a well-formed answer looks like.
223 expected: String,
224 /// What was submitted.
225 found: String,
226 },
227 /// The chosen option is not one the control offers.
228 #[error("control `{control}` does not offer option `{option}`")]
229 UnknownOption {
230 /// The control answered.
231 control: String,
232 /// The option chosen.
233 option: String,
234 },
235 /// The chosen option maps to no caller-registered handler.
236 #[error("option `{option}` maps to no registered handler")]
237 UnknownAction {
238 /// The option chosen.
239 option: String,
240 },
241 /// The action exists but this responder may not invoke it.
242 #[error("option `{option}` is not eligible for the {audience:?} audience")]
243 ActionNotEligible {
244 /// The option chosen.
245 option: String,
246 /// The audience that answered.
247 audience: Audience,
248 },
249 /// Content addressing failed while checking a binding.
250 #[error(transparent)]
251 Protocol(#[from] ProtocolError),
252}
253
254/// The name of a control's kind, for a refusal message.
255fn kind_name(kind: &ControlKind) -> &'static str {
256 match kind {
257 ControlKind::Choice { .. } => "choice",
258 ControlKind::Text => "text",
259 ControlKind::Toggle => "toggle",
260 ControlKind::Secret => "secret",
261 ControlKind::Number { .. } => "number",
262 ControlKind::Range { .. } => "range",
263 ControlKind::Temporal { .. } => "temporal",
264 ControlKind::Color => "color",
265 ControlKind::Path { .. } => "path",
266 }
267}
268
269/// The name of a value's kind, for a refusal message.
270fn value_name(value: &ControlValue) -> &'static str {
271 match value {
272 ControlValue::Choice { .. } => "choice",
273 ControlValue::Text { .. } => "text",
274 ControlValue::Toggle { .. } => "toggle",
275 ControlValue::Secret { .. } => "secret",
276 }
277}
278
279/// Check one submission against the control it claims to answer.
280///
281/// Returns the chosen option when the control is a choice, so the caller
282/// can resolve it to a handler; `None` for the value-carrying kinds,
283/// which route to nothing.
284/// Resolve TYPED input to the option it names, or refuse.
285///
286/// **The one implementation of the canonical-first / alias / ambiguity-denial
287/// rules.** It lived in `newt_core::tty::Question::parse`, over the legacy
288/// `Action` list, and D0 (#1878) moved it here rather than writing a second
289/// one against `ChoiceOption` — which is the third answer-parser this epic's
290/// deletion gate exists to prevent.
291///
292/// It belongs beside [`check_value`] because the two are the same question
293/// asked at different distances: `check_value` resolves a submission that
294/// already names an `OptionId`, this resolves the text a human typed. Both
295/// now live in one module, so "how is an answer resolved" has one answer.
296///
297/// ## The rules, unchanged
298///
299/// * **Canonical first.** An option's `id` (its wire name) or its `key` (the
300/// accelerator) match before any alias is considered. The permission menu is
301/// case-distinct on purpose — `a` is allow-once and `A` is allow-permanent —
302/// so folding case, or letting an alias shadow a canonical key, would let a
303/// weaker answer select a stronger grant.
304/// * **Aliases second**, and only when nothing canonical matched.
305/// * **Ambiguity refuses, at either tier.** Two matches is not a reason to
306/// pick one.
307///
308/// ## Refusal is `None`, and what it MEANS is the caller's
309///
310/// This deliberately returns `Option`, not a default. A definition may come
311/// from untrusted markup, and `ChoiceOption.role` is author-assigned — so
312/// resolving an ambiguous answer by scanning the options for a `Deny` role
313/// would let whoever wrote the definition choose the failure mode. The caller
314/// supplies its own fail-closed constant; nothing here reads a role.
315#[must_use]
316pub fn resolve_typed(options: &[ChoiceOption], input: &str) -> Option<OptionId> {
317 let input = input.trim();
318 let single = |mut matches: std::vec::IntoIter<&ChoiceOption>| -> Option<OptionId> {
319 let first = matches.next()?;
320 // A second match is ambiguity, and ambiguity refuses.
321 matches.next().is_none().then(|| first.id.clone())
322 };
323
324 let canonical: Vec<&ChoiceOption> = options
325 .iter()
326 .filter(|o| o.id.as_str() == input || o.key == input)
327 .collect();
328 if !canonical.is_empty() {
329 return single(canonical.into_iter());
330 }
331 let aliased: Vec<&ChoiceOption> = options
332 .iter()
333 .filter(|o| o.aliases.iter().any(|alias| alias == input))
334 .collect();
335 single(aliased.into_iter())
336}
337
338fn check_value(control: &Control, value: &ControlValue) -> Result<Option<OptionId>, Refusal> {
339 match (&control.kind, value) {
340 (ControlKind::Choice { options }, ControlValue::Choice { option }) => {
341 if options.iter().any(|o| &o.id == option) {
342 Ok(Some(option.clone()))
343 } else {
344 Err(Refusal::UnknownOption {
345 control: control.id.as_str().to_string(),
346 option: option.as_str().to_string(),
347 })
348 }
349 }
350 (ControlKind::Toggle, ControlValue::Toggle { .. })
351 | (ControlKind::Secret, ControlValue::Secret { .. }) => Ok(None),
352 // **The value-shaped kinds all travel as text, and are checked here.**
353 //
354 // One variant on the wire rather than one per kind, for the same
355 // reason HTML gives every input a string value: a `ControlValue` per
356 // kind would put the definition's meaning in two places and let a
357 // response claim a shape the control never declared. What the control
358 // declares is what the answer is measured against — `check_text` is
359 // the single implementation, shared with every surface that wants to
360 // validate as you type.
361 (kind, ControlValue::Text { text }) if kind.travels_as_text() => kind
362 .check_text(text)
363 .map(|()| None)
364 .map_err(|expected| Refusal::MalformedValue {
365 control: control.id.as_str().to_string(),
366 expected,
367 found: text.clone(),
368 }),
369 _ => Err(Refusal::WrongControlType {
370 control: control.id.as_str().to_string(),
371 expected: kind_name(&control.kind),
372 found: value_name(value),
373 }),
374 }
375}
376
377/// Whether the responder presented an assertion that satisfies the offer.
378fn assertion_satisfied(response: &Response) -> bool {
379 match response.responder_provenance.kind {
380 AssertionKind::Unauthenticated => false,
381 AssertionKind::TerminalOperator | AssertionKind::SignedAssertion => {
382 response.responder_provenance.assertion.is_some()
383 }
384 }
385}
386
387/// Validate `response` against the offer it claims to answer.
388///
389/// Every rule is a refusal, and the whole `Vec<Submission>` stands or
390/// falls together: this returns [`Accepted`] with every action resolved,
391/// or a [`Refusal`] and nothing at all.
392///
393/// # Errors
394///
395/// A [`Refusal`] naming the first rule the response broke. No partial
396/// result is produced for a response that breaks any rule.
397pub fn validate_response(
398 definition: &InteractionDefinition,
399 instance: &InteractionInstance,
400 lifecycle: &Lifecycle,
401 response: &Response,
402 context: &ResponderContext<'_>,
403) -> Result<Accepted, Refusal> {
404 // 1. The offer must be open. An expired, cancelled, answered, or
405 // unsupported offer refuses EVERY response, valid or not.
406 if lifecycle.state() != LifecycleState::Published {
407 return Err(Refusal::NotPublished {
408 state: lifecycle.state(),
409 });
410 }
411
412 // 2. The response must answer THIS offer.
413 let instance_id = instance.instance_id().map_err(Refusal::Protocol)?;
414 if response.instance != instance_id {
415 return Err(Refusal::InstanceMismatch {
416 named: response.instance.to_string(),
417 actual: instance_id.to_string(),
418 });
419 }
420 if lifecycle.instance() != &instance_id {
421 return Err(Refusal::InstanceMismatch {
422 named: lifecycle.instance().to_string(),
423 actual: instance_id.to_string(),
424 });
425 }
426
427 // 3. ...and the definition that offer binds...
428 if response.definition != instance.definition {
429 return Err(Refusal::DefinitionMismatch {
430 named: response.definition.to_string(),
431 offered: instance.definition.to_string(),
432 });
433 }
434
435 // 4. ...in the exact form it was minted against. A definition that
436 // changed by one byte has a different id, so a response minted
437 // against the old one cannot answer the new one.
438 let presented = definition.definition_id().map_err(Refusal::Protocol)?;
439 if response.definition != presented {
440 return Err(Refusal::DigestMismatch {
441 bound: response.definition.to_string(),
442 presented: presented.to_string(),
443 });
444 }
445
446 // 5. ...at the revision the responder actually saw.
447 if response.revision != definition.revision {
448 return Err(Refusal::StaleRevision {
449 offered: response.revision.get(),
450 current: definition.revision.get(),
451 });
452 }
453
454 // 6. The workspace fence.
455 if instance.scope.workspace_key != context.workspace_key {
456 return Err(Refusal::WorkspaceMismatch {
457 offered: instance.scope.workspace_key.clone(),
458 caller: context.workspace_key.to_string(),
459 });
460 }
461
462 // 7. Responder policy: audience, then assertion.
463 let audience = response.responder_provenance.audience.clone();
464 if !instance.responder_policy.audiences.contains(&audience) {
465 return Err(Refusal::AudienceMismatch { audience });
466 }
467 if instance.responder_policy.requires_assertion && !assertion_satisfied(response) {
468 return Err(Refusal::AssertionRequired);
469 }
470
471 // 8. The control set, exactly: no extras, no duplicates, every
472 // required control answered, every value of the declared type.
473 let mut answered: Vec<&ControlId> = Vec::new();
474 let mut actions = Vec::new();
475 for submission in &response.values {
476 let Some(control) = definition
477 .controls
478 .iter()
479 .find(|c| c.id == submission.control)
480 else {
481 return Err(Refusal::ExtraControl {
482 control: submission.control.as_str().to_string(),
483 });
484 };
485 if answered.contains(&&submission.control) {
486 return Err(Refusal::DuplicateControl {
487 control: submission.control.as_str().to_string(),
488 });
489 }
490 answered.push(&submission.control);
491
492 if let Some(option) = check_value(control, &submission.value)? {
493 // 9. An action routes ONLY to a handler the caller
494 // registered, and only for an eligible audience.
495 let Some(registration) = context.registered.iter().find(|r| r.option == option) else {
496 return Err(Refusal::UnknownAction {
497 option: option.as_str().to_string(),
498 });
499 };
500 if !registration.audiences.contains(&audience) {
501 return Err(Refusal::ActionNotEligible {
502 option: option.as_str().to_string(),
503 audience,
504 });
505 }
506 actions.push(ResolvedAction {
507 control: control.id.clone(),
508 option,
509 handler: registration.handler.clone(),
510 });
511 }
512 }
513
514 for control in &definition.controls {
515 if control.requirement == Requirement::Required && !answered.contains(&&control.id) {
516 return Err(Refusal::MissingRequiredControl {
517 control: control.id.as_str().to_string(),
518 });
519 }
520 }
521
522 Ok(Accepted {
523 response: response.response_id().map_err(Refusal::Protocol)?,
524 instance: instance_id,
525 actions,
526 })
527}
528
529#[cfg(test)]
530mod model_fidelity {
531 use super::*;
532
533 /// **A third `Audience` variant would silently invalidate the formal
534 /// model, so it must not compile silently.**
535 ///
536 /// `PromptControls.tla` models two racers and a BINARY `SingleWinner`;
537 /// `Audience` is currently exactly `{Terminal, Web}`. It is
538 /// `#[non_exhaustive]`, so no downstream crate can write an
539 /// exhaustive match over it and no downstream test can notice a third
540 /// variant. This crate DEFINES it, so the match below — deliberately
541 /// without a `_` arm — is the one place that can: adding a variant
542 /// breaks compilation here, which is the prompt to revisit the model
543 /// rather than discover the gap later.
544 #[test]
545 fn the_modelled_audience_set_is_exactly_the_two_the_tla_covers() {
546 let modelled = [Audience::Terminal, Audience::Web];
547 for audience in &modelled {
548 match audience {
549 Audience::Terminal | Audience::Web => {}
550 }
551 }
552 assert_eq!(
553 modelled.len(),
554 2,
555 "PromptControls.tla's SingleWinner is binary; a third audience \
556 needs the model revisited, not just this number bumped"
557 );
558 }
559}
560
561#[cfg(test)]
562mod resolve_typed_tests {
563 use super::resolve_typed;
564 use crate::definition::{ChoiceOption, SemanticRole};
565 use crate::ids::OptionId;
566
567 /// **These tests MOVED here with the rule they govern (D0, #1878).**
568 ///
569 /// They lived beside `newt_core::tty::Question::parse`, which was the one
570 /// implementation of canonical-first / alias / ambiguity-denial until D0
571 /// moved it into `resolve_typed` so a second decoder would not have to be
572 /// written against `InteractionDefinition`. The coverage follows the code;
573 /// deleting it and re-deriving it later is how a rule loses its tests.
574 ///
575 /// `BHV-PROMPT-001`'s and `BHV-PROMPT-005`'s refs were repointed in the
576 /// same change.
577 fn option(id: &str, role: SemanticRole, key: &str, aliases: &[&str]) -> ChoiceOption {
578 ChoiceOption {
579 id: OptionId::new(id).expect("valid id"),
580 role,
581 label: id.to_string(),
582 key: key.to_string(),
583 aliases: aliases.iter().map(|a| (*a).to_string()).collect(),
584 }
585 }
586
587 /// The general permission menu: case-distinct `a`/`A` and `d`/`D`.
588 fn menu() -> Vec<ChoiceOption> {
589 vec![
590 option("allow_once", SemanticRole::Allow, "a", &[]),
591 option("allow_permanent", SemanticRole::Allow, "A", &[]),
592 option("deny", SemanticRole::Deny, "d", &[]),
593 option("deny_always", SemanticRole::Deny, "D", &[]),
594 ]
595 }
596
597 /// The mutation-confirm form: y/Y confirm, n/N deny, via hidden aliases.
598 fn confirm() -> Vec<ChoiceOption> {
599 vec![
600 option("allow_once", SemanticRole::Allow, "y", &["Y"]),
601 option("deny", SemanticRole::Deny, "n", &["N"]),
602 ]
603 }
604
605 fn resolved(options: &[ChoiceOption], input: &str) -> Option<String> {
606 resolve_typed(options, input).map(|id| id.as_str().to_string())
607 }
608
609 #[test]
610 fn lowercase_y_confirms() {
611 assert_eq!(resolved(&confirm(), "y").as_deref(), Some("allow_once"));
612 }
613
614 #[test]
615 fn uppercase_y_confirms_via_alias() {
616 assert_eq!(resolved(&confirm(), "Y").as_deref(), Some("allow_once"));
617 }
618
619 #[test]
620 fn lower_and_upper_n_deny() {
621 assert_eq!(resolved(&confirm(), "n").as_deref(), Some("deny"));
622 assert_eq!(resolved(&confirm(), "N").as_deref(), Some("deny"));
623 }
624
625 #[test]
626 fn empty_unknown_and_malformed_input_resolve_to_nothing() {
627 for bad in ["", "q", "yes", "y n", "\u{1b}", "yy"] {
628 assert_eq!(
629 resolved(&confirm(), bad),
630 None,
631 "input {bad:?} must not resolve"
632 );
633 }
634 }
635
636 #[test]
637 fn surrounding_whitespace_is_trimmed() {
638 assert_eq!(resolved(&confirm(), " Y ").as_deref(), Some("allow_once"));
639 assert_eq!(resolved(&confirm(), "\tn\n").as_deref(), Some("deny"));
640 }
641
642 /// Aliases must NOT fold case on the general menu: folding would let a
643 /// weaker answer select a stronger grant.
644 #[test]
645 fn general_menu_parsing_stays_case_sensitive() {
646 assert_eq!(resolved(&menu(), "a").as_deref(), Some("allow_once"));
647 assert_eq!(resolved(&menu(), "A").as_deref(), Some("allow_permanent"));
648 assert_eq!(resolved(&menu(), "d").as_deref(), Some("deny"));
649 assert_eq!(resolved(&menu(), "D").as_deref(), Some("deny_always"));
650 }
651
652 /// The stable wire name resolves as well as the accelerator.
653 #[test]
654 fn value_wire_name_still_matches() {
655 assert_eq!(
656 resolved(&menu(), "allow_permanent").as_deref(),
657 Some("allow_permanent")
658 );
659 }
660
661 /// An alias cannot select an option the form does not offer.
662 #[test]
663 fn an_alias_cannot_select_an_action_absent_from_the_question() {
664 let deny_only = vec![option("deny", SemanticRole::Deny, "n", &["N"])];
665 assert_eq!(resolved(&deny_only, "Y"), None);
666 assert_eq!(resolved(&deny_only, "y"), None);
667 }
668
669 /// **Ambiguity refuses, at BOTH tiers — never "first match wins".**
670 #[test]
671 fn ambiguity_refuses_rather_than_guessing() {
672 // Two options claiming the same canonical key.
673 let dup_key = vec![
674 option("allow_once", SemanticRole::Allow, "x", &[]),
675 option("deny", SemanticRole::Deny, "x", &[]),
676 ];
677 assert_eq!(resolved(&dup_key, "x"), None, "ambiguous key must refuse");
678
679 // Two options claiming the same alias.
680 let dup_alias = vec![
681 option("allow_once", SemanticRole::Allow, "y", &["z"]),
682 option("deny", SemanticRole::Deny, "n", &["z"]),
683 ];
684 assert_eq!(
685 resolved(&dup_alias, "z"),
686 None,
687 "ambiguous alias must refuse"
688 );
689 // …and each is still individually resolvable, or the above passes
690 // because nothing resolves at all.
691 assert_eq!(resolved(&dup_alias, "y").as_deref(), Some("allow_once"));
692 assert_eq!(resolved(&dup_alias, "n").as_deref(), Some("deny"));
693 }
694
695 /// A canonical match wins over an alias, so an alias can never shadow
696 /// another option's real key or wire name.
697 #[test]
698 fn a_canonical_match_beats_an_alias() {
699 let shadowing = vec![
700 option("allow_once", SemanticRole::Allow, "a", &[]),
701 // `deny` claims "a" as an ALIAS — it must not win.
702 option("deny", SemanticRole::Deny, "d", &["a"]),
703 ];
704 assert_eq!(resolved(&shadowing, "a").as_deref(), Some("allow_once"));
705 }
706
707 /// An alias must not shadow another option's WIRE NAME either — canonical
708 /// covers both the key and the id, and it is checked first.
709 #[test]
710 fn an_alias_never_shadows_another_options_wire_name() {
711 let shadowing = vec![
712 option("deny", SemanticRole::Deny, "d", &[]),
713 // `allow_once` claims "deny" — the other option's wire name — as
714 // an alias. Canonical wins.
715 option("allow_once", SemanticRole::Allow, "a", &["deny"]),
716 ];
717 assert_eq!(resolved(&shadowing, "deny").as_deref(), Some("deny"));
718 }
719
720 /// A KEY on one option colliding with the WIRE NAME of another is
721 /// ambiguous at the canonical tier, and refuses.
722 #[test]
723 fn key_value_collision_between_options_fails_closed() {
724 let collide = vec![
725 // this option's KEY is the other's wire name
726 option("allow_once", SemanticRole::Allow, "deny", &[]),
727 option("deny", SemanticRole::Deny, "d", &[]),
728 ];
729 assert_eq!(
730 resolved(&collide, "deny"),
731 None,
732 "a key-vs-wire-name collision is ambiguous and must refuse"
733 );
734 }
735
736 /// **Refusal is `None`, and nothing here reads a role to decide what
737 /// refusal means.** A definition can come from untrusted markup and
738 /// `role` is author-assigned, so a resolver that fell back to the `Deny`
739 /// option would let the author choose the failure mode (A3). Both these
740 /// forms refuse identically despite opposite role layouts.
741 #[test]
742 fn refusal_does_not_consult_the_authored_role() {
743 let deny_first = vec![
744 option("deny", SemanticRole::Deny, "d", &[]),
745 option("allow_once", SemanticRole::Allow, "a", &[]),
746 ];
747 let allow_first = vec![
748 option("allow_once", SemanticRole::Allow, "a", &[]),
749 option("deny", SemanticRole::Deny, "d", &[]),
750 ];
751 // An author who marks EVERYTHING Allow gets no say either.
752 let all_allow = vec![
753 option("allow_once", SemanticRole::Allow, "a", &[]),
754 option("deny", SemanticRole::Allow, "d", &[]),
755 ];
756 for options in [&deny_first, &allow_first, &all_allow] {
757 assert_eq!(
758 resolved(options, "nonsense"),
759 None,
760 "an unresolvable answer yields NOTHING, not a role-chosen option"
761 );
762 }
763 }
764}