Skip to main content

termivar_scanner/
xml_external_entity_review.rs

1//! Pure contracts for one bounded XML external-entity resolution review.
2//!
3//! This module performs no I/O and parses no XML. An operator policy binds one
4//! disposable, application-contained endpoint to an exact anonymous POST
5//! carrying application/xml; charset=utf-8. The only generated documents use
6//! one external general entity and three fixed roles: Control declares but does
7//! not reference the entity, while Candidate and Replay reference independently
8//! allocated callback targets. Transport and provider authority remain with
9//! WebAssessmentRuntime.
10
11use std::{fmt, mem, str::FromStr};
12
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use termivar_oast::{CallbackTarget, NativeOastRoute, PublicOrigin};
16use thiserror::Error;
17use url::{Host, Url};
18use zeroize::{Zeroize, Zeroizing};
19
20/// Exact purpose-oriented policy schema implemented by V1.
21pub const XML_EXTERNAL_ENTITY_REVIEW_POLICY_SCHEMA: &str =
22    "security.xml-external-entity-review-policy/v1";
23/// Exact raw-free audit schema implemented by V1.
24pub const XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA: &str =
25    "security.xml-external-entity-review-audit/v1";
26/// Semantic revision for policy, document, and audit invariants.
27pub const XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM: &str =
28    "security.xml-external-entity-resolution-review/v1";
29/// Stable native action identity reserved for this review.
30pub const XML_EXTERNAL_ENTITY_REVIEW_ACTION_ID: &str =
31    "web.review.xml.external-entity-resolution@1";
32/// Stable knowledge-only capability identity reserved for this review.
33pub const XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID: &str =
34    "xml.external-entity.repeated-outbound-interaction@1";
35/// Exact target method authorized by V1.
36pub const XML_EXTERNAL_ENTITY_REVIEW_METHOD: &str = "POST";
37/// Exact target media type authorized by V1.
38pub const XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE: &str = "application/xml; charset=utf-8";
39/// Exact control semantics used by V1.
40pub const XML_EXTERNAL_ENTITY_REVIEW_CONTROL_MODEL: &str =
41    "external_general_entity_declared_not_referenced";
42
43/// Hard policy-source ceiling.
44pub const MAX_XML_EXTERNAL_ENTITY_REVIEW_POLICY_BYTES: usize = 64 * 1024;
45/// Hard ceiling for the exact endpoint or callback URL.
46pub const MAX_XML_EXTERNAL_ENTITY_URL_BYTES: usize = 2 * 1024;
47/// Hard ceiling for one generated XML request body.
48pub const MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES: usize = 4 * 1024;
49/// Hard retained-response ceiling for each controlled target request.
50pub(crate) const MAX_XML_EXTERNAL_ENTITY_TARGET_RESPONSE_BYTES: u64 = 16 * 1024;
51/// Control, Candidate, and Replay are the exact target request plan.
52pub const XML_EXTERNAL_ENTITY_TARGET_REQUESTS: usize = 3;
53/// V1 owns one logical active verification.
54pub const XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS: usize = 1;
55/// V1 allocates one independent callback for each target leg.
56pub const XML_EXTERNAL_ENTITY_CALLBACKS: usize = 3;
57/// Register, three allocations, preflight, two polls per leg, and cleanup.
58pub const MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS: usize = 12;
59/// Minimum polls allowed for each target leg.
60pub const MIN_XML_EXTERNAL_ENTITY_POLLS_PER_LEG: u16 = 1;
61/// Maximum polls allowed for each target leg.
62pub const MAX_XML_EXTERNAL_ENTITY_POLLS_PER_LEG: u16 = 2;
63/// Minimum interval between bounded polls.
64pub const MIN_XML_EXTERNAL_ENTITY_POLL_INTERVAL_MS: u64 = 250;
65/// Maximum interval between bounded polls.
66pub const MAX_XML_EXTERNAL_ENTITY_POLL_INTERVAL_MS: u64 = 2_000;
67/// Minimum provider session lifetime.
68pub const MIN_XML_EXTERNAL_ENTITY_LIFETIME_MS: u64 = 5_000;
69/// Maximum provider session lifetime.
70pub const MAX_XML_EXTERNAL_ENTITY_LIFETIME_MS: u64 = 30_000;
71/// Minimum administrator bearer-token bytes.
72pub const MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES: usize = 32;
73/// Maximum administrator bearer-token bytes.
74pub const MAX_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES: usize = 4_096;
75
76const POLICY_ID_DOMAIN: &[u8] = b"security.xml-external-entity-review-policy.identity.v1\0";
77const XML_ENTITY_NAME: &str = "termivar_external";
78
79/// Stable non-secret digest of one validated policy.
80#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct XmlExternalEntityPolicyId([u8; 32]);
82
83impl XmlExternalEntityPolicyId {
84    /// Returns a stable pseudonymous wire identity.
85    pub fn to_wire(self) -> String {
86        format!("xml-external-entity-policy-sha256:{}", hex(self.0))
87    }
88
89    /// Returns the domain-separated digest bytes.
90    pub const fn as_bytes(self) -> [u8; 32] {
91        self.0
92    }
93}
94
95impl fmt::Debug for XmlExternalEntityPolicyId {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        formatter.write_str(&self.to_wire())
98    }
99}
100
101impl fmt::Display for XmlExternalEntityPolicyId {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        formatter.write_str(&self.to_wire())
104    }
105}
106
107/// Closed execution environment selected during policy validation.
108#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
109#[serde(rename_all = "snake_case")]
110pub enum XmlExternalEntityExecutionMode {
111    /// Public execution requires exact HTTPS origins with DNS hosts.
112    Production,
113    /// Cleartext numeric loopback is reserved for repository-owned fixtures.
114    OwnedNumericLoopbackTest,
115}
116
117impl XmlExternalEntityExecutionMode {
118    /// Stable raw-free audit token.
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            Self::Production => "production",
122            Self::OwnedNumericLoopbackTest => "owned_numeric_loopback_test",
123        }
124    }
125}
126
127/// Closed target method authorized by the V1 policy.
128#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
129pub enum XmlExternalEntityHttpMethod {
130    /// One exact anonymous POST.
131    #[serde(rename = "POST")]
132    Post,
133}
134
135impl XmlExternalEntityHttpMethod {
136    /// Exact HTTP method token.
137    pub const fn as_str(self) -> &'static str {
138        XML_EXTERNAL_ENTITY_REVIEW_METHOD
139    }
140}
141
142/// Closed target media type authorized by the V1 policy.
143#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
144pub enum XmlExternalEntityMediaType {
145    /// UTF-8 XML without media-type parameters beyond the exact charset.
146    #[serde(rename = "application/xml; charset=utf-8")]
147    ApplicationXmlUtf8,
148}
149
150impl XmlExternalEntityMediaType {
151    /// Exact HTTP media-type value.
152    pub const fn as_str(self) -> &'static str {
153        XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE
154    }
155}
156
157/// Strict operator authorization for one disposable XML endpoint and provider.
158///
159/// The endpoint and provider are private and this type is not serializable.
160/// Its Debug implementation exposes only bounded scheduling facts and a digest.
161pub struct XmlExternalEntityReviewPolicy {
162    application: Url,
163    endpoint: Url,
164    provider_origin: PublicOrigin,
165    execution_mode: XmlExternalEntityExecutionMode,
166    polls_per_leg: u16,
167    poll_interval_ms: u64,
168    lifetime_ms: u64,
169    policy_id: XmlExternalEntityPolicyId,
170}
171
172impl XmlExternalEntityReviewPolicy {
173    /// Parses strict TOML and binds one production policy to the assessment
174    /// application's exact origin and path boundary.
175    pub fn parse_toml(
176        assessment_target: &Url,
177        source: &[u8],
178    ) -> Result<Self, XmlExternalEntityReviewPolicyError> {
179        if source.len() > MAX_XML_EXTERNAL_ENTITY_REVIEW_POLICY_BYTES {
180            return Err(XmlExternalEntityReviewPolicyError::PolicyTooLarge);
181        }
182        let source = std::str::from_utf8(source)
183            .map_err(|_| XmlExternalEntityReviewPolicyError::MalformedPolicy)?;
184        let wire: WirePolicy = toml::from_str(source)
185            .map_err(|_| XmlExternalEntityReviewPolicyError::MalformedPolicy)?;
186
187        if wire.schema != XML_EXTERNAL_ENTITY_REVIEW_POLICY_SCHEMA {
188            return Err(XmlExternalEntityReviewPolicyError::UnsupportedSchema);
189        }
190        if wire.method != XML_EXTERNAL_ENTITY_REVIEW_METHOD {
191            return Err(XmlExternalEntityReviewPolicyError::InvalidMethod);
192        }
193        if wire.media_type != XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE {
194            return Err(XmlExternalEntityReviewPolicyError::InvalidMediaType);
195        }
196        if !wire.acknowledge_xml_post
197            || !wire.acknowledge_external_interaction
198            || !wire.acknowledge_disposable_test_endpoint
199        {
200            return Err(XmlExternalEntityReviewPolicyError::AcknowledgementRequired);
201        }
202        validate_limits(wire.polls_per_leg, wire.poll_interval_ms, wire.lifetime_ms)?;
203
204        validate_production_url(assessment_target)
205            .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidAssessmentTarget)?;
206        let endpoint = parse_exact_policy_endpoint(&wire.endpoint)
207            .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidEndpoint)?;
208        validate_production_url(&endpoint)
209            .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidEndpoint)?;
210        if !same_origin(assessment_target, &endpoint) {
211            return Err(XmlExternalEntityReviewPolicyError::TargetOriginMismatch);
212        }
213        if !application_contains(assessment_target, &endpoint) {
214            return Err(XmlExternalEntityReviewPolicyError::EndpointOutsideApplication);
215        }
216
217        let provider_origin = PublicOrigin::from_str(&wire.provider_origin)
218            .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidProviderOrigin)?;
219        let provider_url = Url::parse(provider_origin.as_str())
220            .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidProviderOrigin)?;
221        if same_origin(&endpoint, &provider_url) {
222            return Err(XmlExternalEntityReviewPolicyError::ProviderOriginMatchesTarget);
223        }
224
225        let execution_mode = XmlExternalEntityExecutionMode::Production;
226        let policy_id = policy_identity(
227            assessment_target,
228            &endpoint,
229            &provider_url,
230            execution_mode,
231            wire.polls_per_leg,
232            wire.poll_interval_ms,
233            wire.lifetime_ms,
234        );
235        Ok(Self {
236            application: assessment_target.clone(),
237            endpoint,
238            provider_origin,
239            execution_mode,
240            polls_per_leg: wire.polls_per_leg,
241            poll_interval_ms: wire.poll_interval_ms,
242            lifetime_ms: wire.lifetime_ms,
243            policy_id,
244        })
245    }
246
247    /// Constructs the same policy around repository-owned numeric-loopback
248    /// fixtures. Production callers cannot access this seam.
249    #[cfg(test)]
250    pub(crate) fn for_owned_loopback(
251        assessment_target: Url,
252        endpoint: Url,
253        provider_origin: PublicOrigin,
254        polls_per_leg: u16,
255        poll_interval_ms: u64,
256        lifetime_ms: u64,
257    ) -> Result<Self, XmlExternalEntityReviewPolicyError> {
258        validate_limits(polls_per_leg, poll_interval_ms, lifetime_ms)?;
259        if !is_owned_http_loopback(&assessment_target) {
260            return Err(XmlExternalEntityReviewPolicyError::InvalidAssessmentTarget);
261        }
262        if !is_owned_http_loopback(&endpoint) {
263            return Err(XmlExternalEntityReviewPolicyError::InvalidEndpoint);
264        }
265        if !same_origin(&assessment_target, &endpoint) {
266            return Err(XmlExternalEntityReviewPolicyError::TargetOriginMismatch);
267        }
268        if !application_contains(&assessment_target, &endpoint) {
269            return Err(XmlExternalEntityReviewPolicyError::EndpointOutsideApplication);
270        }
271        let provider_url = Url::parse(provider_origin.as_str())
272            .map_err(|_| XmlExternalEntityReviewPolicyError::InvalidProviderOrigin)?;
273        if !is_owned_http_loopback(&provider_url) {
274            return Err(XmlExternalEntityReviewPolicyError::InvalidProviderOrigin);
275        }
276        if same_origin(&endpoint, &provider_url) {
277            return Err(XmlExternalEntityReviewPolicyError::ProviderOriginMatchesTarget);
278        }
279
280        let execution_mode = XmlExternalEntityExecutionMode::OwnedNumericLoopbackTest;
281        let policy_id = policy_identity(
282            &assessment_target,
283            &endpoint,
284            &provider_url,
285            execution_mode,
286            polls_per_leg,
287            poll_interval_ms,
288            lifetime_ms,
289        );
290        Ok(Self {
291            application: assessment_target,
292            endpoint,
293            provider_origin,
294            execution_mode,
295            polls_per_leg,
296            poll_interval_ms,
297            lifetime_ms,
298            policy_id,
299        })
300    }
301
302    /// Stable redaction-safe policy identity.
303    pub const fn policy_id(&self) -> XmlExternalEntityPolicyId {
304        self.policy_id
305    }
306
307    /// Stable semantic revision used by this policy.
308    pub const fn policy_revision(&self) -> &'static str {
309        XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM
310    }
311
312    /// Validated execution environment.
313    pub const fn execution_mode(&self) -> XmlExternalEntityExecutionMode {
314        self.execution_mode
315    }
316
317    /// Exact authorized HTTP method.
318    pub const fn method(&self) -> XmlExternalEntityHttpMethod {
319        XmlExternalEntityHttpMethod::Post
320    }
321
322    /// Exact authorized request media type.
323    pub const fn media_type(&self) -> XmlExternalEntityMediaType {
324        XmlExternalEntityMediaType::ApplicationXmlUtf8
325    }
326
327    /// Exact bounded polls allowed for each target leg.
328    pub const fn polls_per_leg(&self) -> u16 {
329        self.polls_per_leg
330    }
331
332    /// Configured interval between bounded polls.
333    pub const fn poll_interval_ms(&self) -> u64 {
334        self.poll_interval_ms
335    }
336
337    /// Configured provider session lifetime.
338    pub const fn lifetime_ms(&self) -> u64 {
339        self.lifetime_ms
340    }
341
342    /// Exact authorized endpoint, available only to the in-crate runtime.
343    pub(crate) const fn endpoint(&self) -> &Url {
344        &self.endpoint
345    }
346
347    /// Rechecks that a consuming runtime owns the exact application used to
348    /// validate this policy. Same-origin sibling paths are not interchangeable.
349    pub(crate) fn is_bound_to_application(&self, application: &Url) -> bool {
350        self.application == *application
351    }
352
353    /// Exact selected application retained for the sealed child broker.
354    pub(crate) const fn application(&self) -> &Url {
355        &self.application
356    }
357
358    /// Exact provider origin, available only to the in-crate runtime.
359    pub(crate) const fn provider_origin(&self) -> &PublicOrigin {
360        &self.provider_origin
361    }
362}
363
364impl fmt::Debug for XmlExternalEntityReviewPolicy {
365    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
366        formatter
367            .debug_struct("XmlExternalEntityReviewPolicy")
368            .field("application", &"<redacted>")
369            .field("endpoint", &"<redacted>")
370            .field("provider_origin", &"<redacted>")
371            .field("execution_mode", &self.execution_mode)
372            .field("polls_per_leg", &self.polls_per_leg)
373            .field("poll_interval_ms", &self.poll_interval_ms)
374            .field("lifetime_ms", &self.lifetime_ms)
375            .field("policy_id", &self.policy_id)
376            .finish()
377    }
378}
379
380/// Static, value-free policy validation failure.
381#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
382pub enum XmlExternalEntityReviewPolicyError {
383    /// Input exceeded the compiled source ceiling.
384    #[error("XML external-entity review policy exceeds its compiled byte limit")]
385    PolicyTooLarge,
386    /// TOML, UTF-8, required fields, or scalar types were invalid.
387    #[error("XML external-entity review policy is malformed")]
388    MalformedPolicy,
389    /// The policy schema is not implemented.
390    #[error("XML external-entity review policy schema is unsupported")]
391    UnsupportedSchema,
392    /// The method was not exact uppercase POST.
393    #[error("XML external-entity review method is invalid")]
394    InvalidMethod,
395    /// The media type was not the exact V1 XML media type.
396    #[error("XML external-entity review media type is invalid")]
397    InvalidMediaType,
398    /// At least one explicit operator acknowledgement was absent.
399    #[error("XML external-entity review requires all explicit acknowledgements")]
400    AcknowledgementRequired,
401    /// One of the fixed V1 scheduling limits was outside its range.
402    #[error("XML external-entity review scheduling limits are invalid")]
403    InvalidLimits,
404    /// The assessment target was not one production HTTPS/DNS application.
405    #[error("XML external-entity review assessment target is invalid")]
406    InvalidAssessmentTarget,
407    /// The declared endpoint was not one bounded exact URL.
408    #[error("XML external-entity review endpoint is invalid")]
409    InvalidEndpoint,
410    /// The declared endpoint did not share the assessment's exact origin.
411    #[error("XML external-entity review endpoint does not match exact-origin authority")]
412    TargetOriginMismatch,
413    /// The declared endpoint escaped the assessment application's path boundary.
414    #[error("XML external-entity review endpoint is outside the selected application")]
415    EndpointOutsideApplication,
416    /// The provider was not one exact supported origin.
417    #[error("XML external-entity review provider origin is invalid")]
418    InvalidProviderOrigin,
419    /// Provider and target origins must be distinct.
420    #[error("XML external-entity review provider origin must differ from target origin")]
421    ProviderOriginMatchesTarget,
422}
423
424#[derive(Deserialize)]
425#[serde(deny_unknown_fields)]
426struct WirePolicy {
427    schema: String,
428    endpoint: String,
429    provider_origin: String,
430    method: String,
431    media_type: String,
432    acknowledge_xml_post: bool,
433    acknowledge_external_interaction: bool,
434    acknowledge_disposable_test_endpoint: bool,
435    polls_per_leg: u16,
436    poll_interval_ms: u64,
437    lifetime_ms: u64,
438}
439
440/// Move-only administrator credential accepted only through a secret boundary.
441pub struct XmlExternalEntityAdminToken {
442    bytes: Vec<u8>,
443}
444
445impl XmlExternalEntityAdminToken {
446    /// Takes ownership of one bounded visible-ASCII Bearer credential.
447    pub fn new(mut bytes: Vec<u8>) -> Result<Self, XmlExternalEntityAdminTokenError> {
448        let valid = (MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES
449            ..=MAX_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES)
450            .contains(&bytes.len())
451            && bytes.iter().all(|byte| (0x21..=0x7e).contains(byte));
452        if !valid {
453            bytes.zeroize();
454            return Err(XmlExternalEntityAdminTokenError::Invalid);
455        }
456        Ok(Self { bytes })
457    }
458
459    /// Consumes the wrapper at the fixed native-provider request boundary.
460    pub(crate) fn into_bytes(mut self) -> Vec<u8> {
461        mem::take(&mut self.bytes)
462    }
463}
464
465impl fmt::Debug for XmlExternalEntityAdminToken {
466    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
467        formatter.write_str("XmlExternalEntityAdminToken(<redacted>)")
468    }
469}
470
471impl Drop for XmlExternalEntityAdminToken {
472    fn drop(&mut self) {
473        self.bytes.zeroize();
474    }
475}
476
477/// Static administrator-token validation failure.
478#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
479pub enum XmlExternalEntityAdminTokenError {
480    /// The supplied bytes were not one bounded visible-ASCII credential.
481    #[error("XML external-entity review administrator token is invalid")]
482    Invalid,
483}
484
485/// Exact role of one generated document in the fixed three-request plan.
486#[derive(Clone, Copy, Debug, Eq, PartialEq)]
487pub(crate) enum XmlExternalEntityDocumentRole {
488    /// Declares the external entity but never references it.
489    Control,
490    /// References the first independently allocated callback target.
491    Candidate,
492    /// References the second independently allocated callback target.
493    Replay,
494}
495
496impl XmlExternalEntityDocumentRole {
497    const fn as_str(self) -> &'static str {
498        match self {
499            Self::Control => "control",
500            Self::Candidate => "candidate",
501            Self::Replay => "replay",
502        }
503    }
504
505    const fn references_entity(self) -> bool {
506        !matches!(self, Self::Control)
507    }
508}
509
510/// Three validated, callback-bearing XML documents.
511///
512/// This type deliberately has no Debug, Clone, or serialization
513/// implementation because each body contains one raw callback target.
514pub(crate) struct XmlExternalEntityDocumentPlan {
515    control: XmlExternalEntityDocument,
516    candidate: XmlExternalEntityDocument,
517    replay: XmlExternalEntityDocument,
518}
519
520impl XmlExternalEntityDocumentPlan {
521    /// Validates three native callback targets and materializes the fixed plan.
522    pub(crate) fn new(
523        policy: &XmlExternalEntityReviewPolicy,
524        control_target: &CallbackTarget,
525        candidate_target: &CallbackTarget,
526        replay_target: &CallbackTarget,
527    ) -> Result<Self, XmlExternalEntityDocumentError> {
528        Self::from_callback_strings(
529            policy,
530            control_target.as_str(),
531            candidate_target.as_str(),
532            replay_target.as_str(),
533        )
534    }
535
536    fn from_callback_strings(
537        policy: &XmlExternalEntityReviewPolicy,
538        control_target: &str,
539        candidate_target: &str,
540        replay_target: &str,
541    ) -> Result<Self, XmlExternalEntityDocumentError> {
542        let control_target = validate_callback_target(policy, control_target)?;
543        let candidate_target = validate_callback_target(policy, candidate_target)?;
544        let replay_target = validate_callback_target(policy, replay_target)?;
545        if control_target == candidate_target
546            || control_target == replay_target
547            || candidate_target == replay_target
548        {
549            return Err(XmlExternalEntityDocumentError::CallbackIdentityConflict);
550        }
551
552        Ok(Self {
553            control: build_document(
554                policy,
555                XmlExternalEntityDocumentRole::Control,
556                control_target.as_str(),
557            )?,
558            candidate: build_document(
559                policy,
560                XmlExternalEntityDocumentRole::Candidate,
561                candidate_target.as_str(),
562            )?,
563            replay: build_document(
564                policy,
565                XmlExternalEntityDocumentRole::Replay,
566                replay_target.as_str(),
567            )?,
568        })
569    }
570
571    /// Test-only seam for proving that the sealed broker rejects policy,
572    /// application, role, and document substitutions before accounting.
573    #[cfg(test)]
574    pub(crate) fn from_callback_strings_for_test(
575        policy: &XmlExternalEntityReviewPolicy,
576        control_target: &str,
577        candidate_target: &str,
578        replay_target: &str,
579    ) -> Result<Self, XmlExternalEntityDocumentError> {
580        Self::from_callback_strings(policy, control_target, candidate_target, replay_target)
581    }
582
583    /// Borrows one exact document for the target broker.
584    pub(crate) const fn document(
585        &self,
586        role: XmlExternalEntityDocumentRole,
587    ) -> &XmlExternalEntityDocument {
588        match role {
589            XmlExternalEntityDocumentRole::Control => &self.control,
590            XmlExternalEntityDocumentRole::Candidate => &self.candidate,
591            XmlExternalEntityDocumentRole::Replay => &self.replay,
592        }
593    }
594}
595
596/// One bounded generated XML body.
597///
598/// Raw bytes are zeroized when dropped and this type deliberately has no
599/// Debug, Clone, or serialization implementation.
600pub(crate) struct XmlExternalEntityDocument {
601    policy_id: XmlExternalEntityPolicyId,
602    role: XmlExternalEntityDocumentRole,
603    bytes: Zeroizing<Vec<u8>>,
604}
605
606impl XmlExternalEntityDocument {
607    /// Exact immutable policy that minted this document.
608    pub(crate) const fn policy_id(&self) -> XmlExternalEntityPolicyId {
609        self.policy_id
610    }
611
612    /// Exact immutable role encoded in this document.
613    pub(crate) const fn role(&self) -> XmlExternalEntityDocumentRole {
614        self.role
615    }
616
617    /// Borrows exact body bytes for one metered target request.
618    pub(crate) fn as_bytes(&self) -> &[u8] {
619        self.bytes.as_slice()
620    }
621}
622
623/// Static document-plan validation failure.
624#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
625pub(crate) enum XmlExternalEntityDocumentError {
626    /// A callback was not one exact route under the policy provider.
627    #[error("XML external-entity callback target is invalid")]
628    InvalidCallbackTarget,
629    /// Two plan legs were assigned the same callback target.
630    #[error("XML external-entity callback targets must be distinct")]
631    CallbackIdentityConflict,
632    /// A generated body exceeded its compiled byte ceiling.
633    #[error("XML external-entity document exceeds its compiled byte limit")]
634    DocumentTooLarge,
635}
636
637/// Closed, raw-free conclusion retained by the typed audit.
638#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
639#[serde(rename_all = "snake_case")]
640pub enum XmlExternalEntityReviewOutcome {
641    /// The fixed plan was not eligible for dispatch.
642    NotEligible,
643    /// The control lifecycle did not complete.
644    ControlIncomplete,
645    /// Provider preflight observed stale or background callback activity.
646    PreflightContaminated,
647    /// The unreferenced control declaration produced a callback.
648    ControlCallbackObserved,
649    /// No candidate or replay callback was observed.
650    NoCallback,
651    /// Only the candidate callback was observed.
652    CandidateOnly,
653    /// Only the replay callback was observed.
654    ReplayOnly,
655    /// A callback or event identity violated exact correlation.
656    CorrelationMismatch,
657    /// Provider cleanup could not be verified.
658    CleanupIncomplete,
659    /// Host cancellation stopped the bounded review.
660    Cancelled,
661    /// Parent request, byte, verification, or time authority was exhausted.
662    BudgetExhausted,
663    /// The lifecycle ended without a complete classifiable result.
664    Incomplete,
665    /// Candidate and replay produced distinct exactly correlated callbacks.
666    RepeatedExternalEntityResolutionObserved,
667}
668
669impl XmlExternalEntityReviewOutcome {
670    /// Stable raw-free wire token.
671    pub const fn as_str(self) -> &'static str {
672        match self {
673            Self::NotEligible => "not_eligible",
674            Self::ControlIncomplete => "control_incomplete",
675            Self::PreflightContaminated => "preflight_contaminated",
676            Self::ControlCallbackObserved => "control_callback_observed",
677            Self::NoCallback => "no_callback",
678            Self::CandidateOnly => "candidate_only",
679            Self::ReplayOnly => "replay_only",
680            Self::CorrelationMismatch => "correlation_mismatch",
681            Self::CleanupIncomplete => "cleanup_incomplete",
682            Self::Cancelled => "cancelled",
683            Self::BudgetExhausted => "budget_exhausted",
684            Self::Incomplete => "incomplete",
685            Self::RepeatedExternalEntityResolutionObserved => {
686                "repeated_external_entity_resolution_observed"
687            },
688        }
689    }
690}
691
692/// Maximum product disposition authorized by this evidence family.
693#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
694#[serde(rename_all = "snake_case")]
695pub enum XmlExternalEntityMaximumDisposition {
696    /// Correlated callbacks require human review and never confirm impact.
697    NeedsReview,
698}
699
700impl XmlExternalEntityMaximumDisposition {
701    /// Stable product-disposition token.
702    pub const fn as_str(self) -> &'static str {
703        "needs_review"
704    }
705}
706
707/// Closed state for excluded semantic or impact operations.
708#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
709#[serde(rename_all = "snake_case")]
710pub enum XmlExternalEntityOperationStatus {
711    /// The operation was deliberately not performed.
712    NotPerformed,
713}
714
715impl XmlExternalEntityOperationStatus {
716    /// Stable excluded-operation token.
717    pub const fn as_str(self) -> &'static str {
718        "not_performed"
719    }
720}
721
722/// Explicit ceiling on claims derivable from a completed callback review.
723#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
724pub struct XmlExternalEntityClaimLimits {
725    maximum_disposition: XmlExternalEntityMaximumDisposition,
726    semantic_effect: XmlExternalEntityOperationStatus,
727    impact_validation: XmlExternalEntityOperationStatus,
728}
729
730impl XmlExternalEntityClaimLimits {
731    const fn v1() -> Self {
732        Self {
733            maximum_disposition: XmlExternalEntityMaximumDisposition::NeedsReview,
734            semantic_effect: XmlExternalEntityOperationStatus::NotPerformed,
735            impact_validation: XmlExternalEntityOperationStatus::NotPerformed,
736        }
737    }
738
739    /// Strongest product disposition allowed by this contract.
740    pub const fn maximum_disposition(self) -> XmlExternalEntityMaximumDisposition {
741        self.maximum_disposition
742    }
743
744    /// Server-side semantic effect was not tested.
745    pub const fn semantic_effect(self) -> XmlExternalEntityOperationStatus {
746        self.semantic_effect
747    }
748
749    /// Exploitability and business impact were not tested.
750    pub const fn impact_validation(self) -> XmlExternalEntityOperationStatus {
751        self.impact_validation
752    }
753}
754
755/// Raw-free runtime facts accepted by the audit invariant.
756pub(crate) struct XmlExternalEntityAuditFacts {
757    pub(crate) outcome: XmlExternalEntityReviewOutcome,
758    pub(crate) target_request_count: u8,
759    pub(crate) provider_request_count: u8,
760    pub(crate) active_verification_count: u8,
761    pub(crate) target_request_body_bytes: u64,
762    pub(crate) target_response_bytes: u64,
763    pub(crate) target_complete: bool,
764    pub(crate) preflight_clean: bool,
765    pub(crate) control_callback_observed: bool,
766    pub(crate) candidate_callback_observed: bool,
767    pub(crate) replay_callback_observed: bool,
768    pub(crate) callback_targets_distinct: bool,
769    pub(crate) event_identities_distinct: bool,
770    pub(crate) cleanup_verified: bool,
771    pub(crate) target_accounting_complete: bool,
772    pub(crate) provider_accounting_complete: bool,
773    pub(crate) item_projected: bool,
774}
775
776/// Redaction-safe audit retained by one composed assessment report.
777#[derive(Clone, Eq, PartialEq, Serialize)]
778pub struct XmlExternalEntityReviewAudit {
779    schema: &'static str,
780    algorithm: &'static str,
781    action_id: &'static str,
782    capability_id: &'static str,
783    outcome: XmlExternalEntityReviewOutcome,
784    policy_id: String,
785    execution_mode: XmlExternalEntityExecutionMode,
786    target_request_count: u8,
787    provider_request_count: u8,
788    active_verification_count: u8,
789    target_request_body_bytes: u64,
790    target_response_bytes: u64,
791    target_complete: bool,
792    preflight_clean: bool,
793    control_callback_observed: bool,
794    candidate_callback_observed: bool,
795    replay_callback_observed: bool,
796    callback_targets_distinct: bool,
797    event_identities_distinct: bool,
798    cleanup_verified: bool,
799    target_accounting_complete: bool,
800    provider_accounting_complete: bool,
801    item_projected: bool,
802    claim_limits: XmlExternalEntityClaimLimits,
803}
804
805impl XmlExternalEntityReviewAudit {
806    /// Constructs an audit only when counts, correlation, projection, and claim
807    /// ceilings form one internally consistent V1 result.
808    pub(crate) fn from_runtime(
809        policy: &XmlExternalEntityReviewPolicy,
810        facts: XmlExternalEntityAuditFacts,
811    ) -> Result<Self, XmlExternalEntityAuditError> {
812        validate_audit_facts(&facts)?;
813        Ok(Self {
814            schema: XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA,
815            algorithm: XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM,
816            action_id: XML_EXTERNAL_ENTITY_REVIEW_ACTION_ID,
817            capability_id: XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID,
818            outcome: facts.outcome,
819            policy_id: policy.policy_id().to_wire(),
820            execution_mode: policy.execution_mode(),
821            target_request_count: facts.target_request_count,
822            provider_request_count: facts.provider_request_count,
823            active_verification_count: facts.active_verification_count,
824            target_request_body_bytes: facts.target_request_body_bytes,
825            target_response_bytes: facts.target_response_bytes,
826            target_complete: facts.target_complete,
827            preflight_clean: facts.preflight_clean,
828            control_callback_observed: facts.control_callback_observed,
829            candidate_callback_observed: facts.candidate_callback_observed,
830            replay_callback_observed: facts.replay_callback_observed,
831            callback_targets_distinct: facts.callback_targets_distinct,
832            event_identities_distinct: facts.event_identities_distinct,
833            cleanup_verified: facts.cleanup_verified,
834            target_accounting_complete: facts.target_accounting_complete,
835            provider_accounting_complete: facts.provider_accounting_complete,
836            item_projected: facts.item_projected,
837            claim_limits: XmlExternalEntityClaimLimits::v1(),
838        })
839    }
840
841    /// Stable audit schema.
842    pub const fn schema(&self) -> &'static str {
843        self.schema
844    }
845
846    /// Stable review algorithm revision.
847    pub const fn algorithm(&self) -> &'static str {
848        self.algorithm
849    }
850
851    /// Stable semantic revision used by this audit.
852    pub const fn policy_revision(&self) -> &'static str {
853        self.algorithm
854    }
855
856    /// Stable action identity.
857    pub const fn action_id(&self) -> &'static str {
858        self.action_id
859    }
860
861    /// Stable capability identity.
862    pub const fn capability_id(&self) -> &'static str {
863        self.capability_id
864    }
865
866    /// Exact target method used by every request leg.
867    pub const fn method(&self) -> &'static str {
868        XML_EXTERNAL_ENTITY_REVIEW_METHOD
869    }
870
871    /// Exact target media type used by every request leg.
872    pub const fn media_type(&self) -> &'static str {
873        XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE
874    }
875
876    /// Stable description of the inert control construction.
877    pub const fn control_model(&self) -> &'static str {
878        XML_EXTERNAL_ENTITY_REVIEW_CONTROL_MODEL
879    }
880
881    /// Raw-free terminal outcome.
882    pub const fn outcome(&self) -> XmlExternalEntityReviewOutcome {
883        self.outcome
884    }
885
886    /// Stable pseudonymous policy identity.
887    pub fn policy_id(&self) -> &str {
888        &self.policy_id
889    }
890
891    /// Validated execution environment.
892    pub const fn execution_mode(&self) -> XmlExternalEntityExecutionMode {
893        self.execution_mode
894    }
895
896    /// Target requests admitted by the parent broker.
897    pub const fn target_request_count(&self) -> u8 {
898        self.target_request_count
899    }
900
901    /// Provider operations admitted by the parent provider permit.
902    pub const fn provider_request_count(&self) -> u8 {
903        self.provider_request_count
904    }
905
906    /// Logical active verifications consumed by this review.
907    pub const fn active_verification_count(&self) -> u8 {
908        self.active_verification_count
909    }
910
911    /// Total XML request-body bytes reconciled with target receipts.
912    pub const fn target_request_body_bytes(&self) -> u64 {
913        self.target_request_body_bytes
914    }
915
916    /// Total retained target response bytes reconciled with target receipts.
917    pub const fn target_response_bytes(&self) -> u64 {
918        self.target_response_bytes
919    }
920
921    /// Whether all three target receipts were complete.
922    pub const fn target_complete(&self) -> bool {
923        self.target_complete
924    }
925
926    /// Whether the provider preflight was empty.
927    pub const fn preflight_clean(&self) -> bool {
928        self.preflight_clean
929    }
930
931    /// Whether the unreferenced control entity caused an interaction.
932    pub const fn control_callback_observed(&self) -> bool {
933        self.control_callback_observed
934    }
935
936    /// Whether the candidate interaction was exactly correlated.
937    pub const fn candidate_callback_observed(&self) -> bool {
938        self.candidate_callback_observed
939    }
940
941    /// Whether the replay interaction was exactly correlated.
942    pub const fn replay_callback_observed(&self) -> bool {
943        self.replay_callback_observed
944    }
945
946    /// Whether all allocated callback targets were distinct.
947    pub const fn callback_targets_distinct(&self) -> bool {
948        self.callback_targets_distinct
949    }
950
951    /// Whether candidate and replay event identities were distinct.
952    pub const fn event_identities_distinct(&self) -> bool {
953        self.event_identities_distinct
954    }
955
956    /// Count of distinct correlated candidate/replay event identities.
957    pub const fn distinct_event_count(&self) -> u8 {
958        if self.event_identities_distinct {
959            2
960        } else if self.candidate_callback_observed || self.replay_callback_observed {
961            1
962        } else {
963            0
964        }
965    }
966
967    /// Whether explicit provider cleanup was verified.
968    pub const fn cleanup_verified(&self) -> bool {
969        self.cleanup_verified
970    }
971
972    /// Whether target accounting reconciled with parent receipts.
973    pub const fn target_accounting_complete(&self) -> bool {
974        self.target_accounting_complete
975    }
976
977    /// Whether provider accounting reconciled with parent receipts.
978    pub const fn provider_accounting_complete(&self) -> bool {
979        self.provider_accounting_complete
980    }
981
982    /// Whether provider cleanup and parent accounting both completed.
983    pub const fn provider_complete(&self) -> bool {
984        self.cleanup_verified && self.provider_accounting_complete
985    }
986
987    /// Whether a knowledge-only NeedsReview item was projected.
988    pub const fn item_projected(&self) -> bool {
989        self.item_projected
990    }
991
992    /// Explicit ceiling on semantic and impact claims.
993    pub const fn claim_limits(&self) -> XmlExternalEntityClaimLimits {
994        self.claim_limits
995    }
996
997    /// Server-side semantic effect was deliberately not tested.
998    pub const fn semantic_effect(&self) -> XmlExternalEntityOperationStatus {
999        self.claim_limits.semantic_effect()
1000    }
1001
1002    /// Exploitability and business impact were deliberately not tested.
1003    pub const fn impact_validation(&self) -> XmlExternalEntityOperationStatus {
1004        self.claim_limits.impact_validation()
1005    }
1006}
1007
1008impl fmt::Debug for XmlExternalEntityReviewAudit {
1009    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1010        formatter
1011            .debug_struct("XmlExternalEntityReviewAudit")
1012            .field("schema", &self.schema)
1013            .field("algorithm", &self.algorithm)
1014            .field("action_id", &self.action_id)
1015            .field("capability_id", &self.capability_id)
1016            .field("outcome", &self.outcome)
1017            .field("policy_id", &self.policy_id)
1018            .field("execution_mode", &self.execution_mode)
1019            .field("target_request_count", &self.target_request_count)
1020            .field("provider_request_count", &self.provider_request_count)
1021            .field("active_verification_count", &self.active_verification_count)
1022            .field("target_request_body_bytes", &self.target_request_body_bytes)
1023            .field("target_response_bytes", &self.target_response_bytes)
1024            .field("target_complete", &self.target_complete)
1025            .field("preflight_clean", &self.preflight_clean)
1026            .field("control_callback_observed", &self.control_callback_observed)
1027            .field(
1028                "candidate_callback_observed",
1029                &self.candidate_callback_observed,
1030            )
1031            .field("replay_callback_observed", &self.replay_callback_observed)
1032            .field("callback_targets_distinct", &self.callback_targets_distinct)
1033            .field("event_identities_distinct", &self.event_identities_distinct)
1034            .field("cleanup_verified", &self.cleanup_verified)
1035            .field(
1036                "target_accounting_complete",
1037                &self.target_accounting_complete,
1038            )
1039            .field(
1040                "provider_accounting_complete",
1041                &self.provider_accounting_complete,
1042            )
1043            .field("item_projected", &self.item_projected)
1044            .field("claim_limits", &self.claim_limits)
1045            .finish()
1046    }
1047}
1048
1049/// Static audit-invariant failure.
1050#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
1051pub enum XmlExternalEntityAuditError {
1052    /// Target, provider, or active-verification counts exceeded hard bounds.
1053    #[error("XML external-entity review audit counts are invalid")]
1054    InvalidCounts,
1055    /// Callback or event facts contradicted each other.
1056    #[error("XML external-entity review audit correlation facts are invalid")]
1057    InvalidCorrelation,
1058    /// The terminal outcome contradicted the retained lifecycle facts.
1059    #[error("XML external-entity review audit outcome is invalid")]
1060    InvalidOutcome,
1061    /// Item projection exceeded the knowledge-only positive outcome.
1062    #[error("XML external-entity review audit projection is invalid")]
1063    InvalidProjection,
1064}
1065
1066fn validate_limits(
1067    polls_per_leg: u16,
1068    poll_interval_ms: u64,
1069    lifetime_ms: u64,
1070) -> Result<(), XmlExternalEntityReviewPolicyError> {
1071    let minimum_poll_window_ms = u64::from(polls_per_leg)
1072        .checked_mul(3)
1073        .and_then(|polls| polls.checked_mul(poll_interval_ms));
1074    if !(MIN_XML_EXTERNAL_ENTITY_POLLS_PER_LEG..=MAX_XML_EXTERNAL_ENTITY_POLLS_PER_LEG)
1075        .contains(&polls_per_leg)
1076        || !(MIN_XML_EXTERNAL_ENTITY_POLL_INTERVAL_MS..=MAX_XML_EXTERNAL_ENTITY_POLL_INTERVAL_MS)
1077            .contains(&poll_interval_ms)
1078        || !(MIN_XML_EXTERNAL_ENTITY_LIFETIME_MS..=MAX_XML_EXTERNAL_ENTITY_LIFETIME_MS)
1079            .contains(&lifetime_ms)
1080        || minimum_poll_window_ms.is_none_or(|minimum| lifetime_ms <= minimum)
1081    {
1082        return Err(XmlExternalEntityReviewPolicyError::InvalidLimits);
1083    }
1084    Ok(())
1085}
1086
1087fn validate_production_url(url: &Url) -> Result<(), ()> {
1088    if !valid_url_shape(url)
1089        || url.scheme() != "https"
1090        || !matches!(url.host(), Some(Host::Domain(host)) if valid_dns_host(host))
1091    {
1092        return Err(());
1093    }
1094    Ok(())
1095}
1096
1097fn parse_exact_policy_endpoint(value: &str) -> Result<Url, ()> {
1098    // URL parsing follows the URL Standard and can erase literal or encoded
1099    // dot segments, translate backslashes, and canonicalize the authority. An
1100    // operator policy is request authority, so its raw spelling must already
1101    // be the exact bounded URL that will reach the broker. Normalization must
1102    // never turn rejected text into an admitted endpoint.
1103    if value.is_empty()
1104        || value.len() > MAX_XML_EXTERNAL_ENTITY_URL_BYTES
1105        || value.trim() != value
1106        || !value.is_ascii()
1107        || value.bytes().any(|byte| byte.is_ascii_control())
1108        || value.contains('%')
1109        || value.contains('\\')
1110    {
1111        return Err(());
1112    }
1113    let parsed = Url::parse(value).map_err(|_| ())?;
1114    if parsed.as_str() != value {
1115        return Err(());
1116    }
1117    Ok(parsed)
1118}
1119
1120fn valid_url_shape(url: &Url) -> bool {
1121    url.as_str().len() <= MAX_XML_EXTERNAL_ENTITY_URL_BYTES
1122        && !url.cannot_be_a_base()
1123        && url.has_host()
1124        && url.port_or_known_default().is_some_and(|port| port != 0)
1125        && url.username().is_empty()
1126        && url.password().is_none()
1127        && url.query().is_none()
1128        && url.fragment().is_none()
1129        && safe_unambiguous_path(url.path())
1130}
1131
1132fn safe_unambiguous_path(path: &str) -> bool {
1133    !path.is_empty()
1134        && path.starts_with('/')
1135        && path
1136            .bytes()
1137            .all(|byte| (0x21..=0x7e).contains(&byte) && !matches!(byte, b'%' | b'\\'))
1138}
1139
1140fn valid_dns_host(host: &str) -> bool {
1141    !host.eq_ignore_ascii_case("localhost")
1142        && !host.ends_with(".localhost")
1143        && !host.is_empty()
1144        && host.len() <= 253
1145        && host.split('.').all(|label| {
1146            !label.is_empty()
1147                && label.len() <= 63
1148                && !label.starts_with('-')
1149                && !label.ends_with('-')
1150                && label
1151                    .bytes()
1152                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1153        })
1154}
1155
1156fn application_contains(application: &Url, endpoint: &Url) -> bool {
1157    if !same_origin(application, endpoint) {
1158        return false;
1159    }
1160    let application_segments = nonempty_path_segments(application);
1161    let endpoint_segments = nonempty_path_segments(endpoint);
1162    application_segments.len() <= endpoint_segments.len()
1163        && application_segments
1164            .iter()
1165            .zip(endpoint_segments.iter())
1166            .all(|(application, endpoint)| application == endpoint)
1167}
1168
1169fn nonempty_path_segments(url: &Url) -> Vec<&str> {
1170    url.path_segments()
1171        .map(|segments| segments.filter(|segment| !segment.is_empty()).collect())
1172        .unwrap_or_default()
1173}
1174
1175fn same_origin(left: &Url, right: &Url) -> bool {
1176    left.scheme() == right.scheme()
1177        && left.host() == right.host()
1178        && left.port_or_known_default() == right.port_or_known_default()
1179}
1180
1181#[cfg(test)]
1182fn is_owned_http_loopback(url: &Url) -> bool {
1183    valid_url_shape(url)
1184        && url.scheme() == "http"
1185        && url.port().is_some_and(|port| port != 0)
1186        && match url.host() {
1187            Some(Host::Ipv4(address)) => address.is_loopback(),
1188            Some(Host::Ipv6(address)) => address.is_loopback(),
1189            _ => false,
1190        }
1191}
1192
1193fn policy_identity(
1194    application: &Url,
1195    endpoint: &Url,
1196    provider: &Url,
1197    execution_mode: XmlExternalEntityExecutionMode,
1198    polls_per_leg: u16,
1199    poll_interval_ms: u64,
1200    lifetime_ms: u64,
1201) -> XmlExternalEntityPolicyId {
1202    let polls = polls_per_leg.to_be_bytes();
1203    let interval = poll_interval_ms.to_be_bytes();
1204    let lifetime = lifetime_ms.to_be_bytes();
1205    XmlExternalEntityPolicyId(digest_parts(
1206        POLICY_ID_DOMAIN,
1207        &[
1208            XML_EXTERNAL_ENTITY_REVIEW_POLICY_SCHEMA.as_bytes(),
1209            XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM.as_bytes(),
1210            XML_EXTERNAL_ENTITY_REVIEW_METHOD.as_bytes(),
1211            XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE.as_bytes(),
1212            application.as_str().as_bytes(),
1213            endpoint.as_str().as_bytes(),
1214            provider.as_str().as_bytes(),
1215            execution_mode.as_str().as_bytes(),
1216            &polls,
1217            &interval,
1218            &lifetime,
1219        ],
1220    ))
1221}
1222
1223fn validate_callback_target(
1224    policy: &XmlExternalEntityReviewPolicy,
1225    source: &str,
1226) -> Result<Url, XmlExternalEntityDocumentError> {
1227    if source.is_empty() || source.len() > MAX_XML_EXTERNAL_ENTITY_URL_BYTES {
1228        return Err(XmlExternalEntityDocumentError::InvalidCallbackTarget);
1229    }
1230    let parsed =
1231        Url::parse(source).map_err(|_| XmlExternalEntityDocumentError::InvalidCallbackTarget)?;
1232    let provider = Url::parse(policy.provider_origin().as_str())
1233        .map_err(|_| XmlExternalEntityDocumentError::InvalidCallbackTarget)?;
1234    let environment_valid = match policy.execution_mode() {
1235        XmlExternalEntityExecutionMode::Production => {
1236            parsed.scheme() == "https" && validate_production_url(&parsed).is_ok()
1237        },
1238        XmlExternalEntityExecutionMode::OwnedNumericLoopbackTest => {
1239            #[cfg(test)]
1240            {
1241                is_owned_http_loopback(&parsed)
1242            }
1243            #[cfg(not(test))]
1244            {
1245                false
1246            }
1247        },
1248    };
1249    if !environment_valid
1250        || !same_origin(&parsed, &provider)
1251        || !matches!(
1252            NativeOastRoute::from_str(parsed.path()),
1253            Ok(NativeOastRoute::Callback { .. })
1254        )
1255    {
1256        return Err(XmlExternalEntityDocumentError::InvalidCallbackTarget);
1257    }
1258    Ok(parsed)
1259}
1260
1261fn build_document(
1262    policy: &XmlExternalEntityReviewPolicy,
1263    role: XmlExternalEntityDocumentRole,
1264    callback_target: &str,
1265) -> Result<XmlExternalEntityDocument, XmlExternalEntityDocumentError> {
1266    let callback_target = escape_xml_attribute(callback_target);
1267    let content = if role.references_entity() {
1268        format!("&{XML_ENTITY_NAME};")
1269    } else {
1270        "control".to_owned()
1271    };
1272    let body = format!(
1273        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
1274<!DOCTYPE termivar-review [<!ENTITY {XML_ENTITY_NAME} SYSTEM \"{callback_target}\">]>\n\
1275<termivar-review role=\"{}\">{content}</termivar-review>\n",
1276        role.as_str()
1277    )
1278    .into_bytes();
1279    if body.len() > MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES {
1280        return Err(XmlExternalEntityDocumentError::DocumentTooLarge);
1281    }
1282    Ok(XmlExternalEntityDocument {
1283        policy_id: policy.policy_id(),
1284        role,
1285        bytes: Zeroizing::new(body),
1286    })
1287}
1288
1289fn escape_xml_attribute(source: &str) -> String {
1290    let mut escaped = String::with_capacity(source.len());
1291    for character in source.chars() {
1292        match character {
1293            '&' => escaped.push_str("&amp;"),
1294            '<' => escaped.push_str("&lt;"),
1295            '>' => escaped.push_str("&gt;"),
1296            '"' => escaped.push_str("&quot;"),
1297            '\'' => escaped.push_str("&apos;"),
1298            character => escaped.push(character),
1299        }
1300    }
1301    escaped
1302}
1303
1304fn validate_audit_facts(
1305    facts: &XmlExternalEntityAuditFacts,
1306) -> Result<(), XmlExternalEntityAuditError> {
1307    let maximum_target_body_bytes = u64::try_from(XML_EXTERNAL_ENTITY_TARGET_REQUESTS)
1308        .ok()
1309        .and_then(|requests| {
1310            u64::try_from(MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES)
1311                .ok()
1312                .and_then(|bytes| requests.checked_mul(bytes))
1313        });
1314    let maximum_complete_target_response_bytes = u64::try_from(XML_EXTERNAL_ENTITY_TARGET_REQUESTS)
1315        .ok()
1316        .and_then(|requests| requests.checked_mul(MAX_XML_EXTERNAL_ENTITY_TARGET_RESPONSE_BYTES));
1317    if usize::from(facts.target_request_count) > XML_EXTERNAL_ENTITY_TARGET_REQUESTS
1318        || usize::from(facts.provider_request_count) > MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS
1319        || usize::from(facts.active_verification_count) > XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS
1320        || maximum_target_body_bytes.is_none_or(|maximum| facts.target_request_body_bytes > maximum)
1321        || (facts.target_complete
1322            && maximum_complete_target_response_bytes
1323                .is_none_or(|maximum| facts.target_response_bytes > maximum))
1324    {
1325        return Err(XmlExternalEntityAuditError::InvalidCounts);
1326    }
1327    let expected_active_verification_count = u8::from(facts.target_request_count >= 2);
1328    let target_bytes_consistent =
1329        (facts.target_request_count == 0) == (facts.target_request_body_bytes == 0);
1330    let target_completion_consistent = !facts.target_complete
1331        || usize::from(facts.target_request_count) == XML_EXTERNAL_ENTITY_TARGET_REQUESTS;
1332    let target_accounting_consistent = !facts.target_accounting_complete || facts.target_complete;
1333    let callback_facts_consistent = (!facts.control_callback_observed
1334        && !facts.candidate_callback_observed
1335        && !facts.replay_callback_observed)
1336        || facts.callback_targets_distinct;
1337    let event_facts_consistent = !facts.event_identities_distinct
1338        || (facts.callback_targets_distinct
1339            && facts.candidate_callback_observed
1340            && facts.replay_callback_observed);
1341    let provider_empty_consistent = facts.provider_request_count != 0
1342        || (facts.active_verification_count == 0
1343            && !facts.preflight_clean
1344            && !facts.control_callback_observed
1345            && !facts.candidate_callback_observed
1346            && !facts.replay_callback_observed
1347            && !facts.callback_targets_distinct
1348            && !facts.event_identities_distinct
1349            && !facts.cleanup_verified
1350            && !facts.provider_accounting_complete);
1351    let provider_completion_consistent = !facts.provider_accounting_complete
1352        || (usize::from(facts.provider_request_count) >= 9
1353            && facts.preflight_clean
1354            && facts.callback_targets_distinct);
1355    let target_started_consistent = facts.target_request_count == 0
1356        || (facts.provider_request_count > 0
1357            && facts.preflight_clean
1358            && facts.callback_targets_distinct);
1359    if facts.active_verification_count != expected_active_verification_count
1360        || !target_bytes_consistent
1361        || !target_completion_consistent
1362        || !target_accounting_consistent
1363        || !callback_facts_consistent
1364        || !event_facts_consistent
1365        || !provider_empty_consistent
1366        || !provider_completion_consistent
1367        || !target_started_consistent
1368    {
1369        return Err(XmlExternalEntityAuditError::InvalidCorrelation);
1370    }
1371
1372    let complete_lifecycle = usize::from(facts.target_request_count)
1373        == XML_EXTERNAL_ENTITY_TARGET_REQUESTS
1374        && facts.provider_request_count > 0
1375        && usize::from(facts.active_verification_count) == XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS
1376        && facts.target_request_body_bytes > 0
1377        && facts.target_complete
1378        && facts.preflight_clean
1379        && !facts.control_callback_observed
1380        && facts.callback_targets_distinct
1381        && facts.cleanup_verified
1382        && facts.target_accounting_complete
1383        && facts.provider_accounting_complete;
1384    let complete_positive = complete_lifecycle
1385        && facts.candidate_callback_observed
1386        && facts.replay_callback_observed
1387        && facts.event_identities_distinct;
1388    let positive_outcome =
1389        facts.outcome == XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved;
1390    if positive_outcome != complete_positive {
1391        return Err(XmlExternalEntityAuditError::InvalidOutcome);
1392    }
1393
1394    let no_callbacks = !facts.control_callback_observed
1395        && !facts.candidate_callback_observed
1396        && !facts.replay_callback_observed;
1397    let empty_lifecycle = facts.target_request_count == 0
1398        && facts.provider_request_count == 0
1399        && no_callbacks
1400        && !facts.target_complete
1401        && !facts.preflight_clean
1402        && !facts.callback_targets_distinct
1403        && !facts.event_identities_distinct
1404        && !facts.cleanup_verified
1405        && !facts.target_accounting_complete
1406        && !facts.provider_accounting_complete;
1407    let stopped_lifecycle = !complete_lifecycle
1408        && !facts.control_callback_observed
1409        && (!facts.target_accounting_complete || !facts.provider_accounting_complete);
1410    let outcome_facts_match = match facts.outcome {
1411        XmlExternalEntityReviewOutcome::NotEligible => empty_lifecycle,
1412        XmlExternalEntityReviewOutcome::ControlIncomplete => {
1413            facts.target_request_count == 0
1414                && !facts.target_complete
1415                && !facts.preflight_clean
1416                && no_callbacks
1417                && !facts.target_accounting_complete
1418                && !facts.provider_accounting_complete
1419        },
1420        XmlExternalEntityReviewOutcome::PreflightContaminated => {
1421            facts.target_request_count == 0
1422                && facts.provider_request_count > 0
1423                && !facts.preflight_clean
1424                && !no_callbacks
1425                && facts.callback_targets_distinct
1426                && !facts.target_complete
1427                && !facts.target_accounting_complete
1428                && !facts.provider_accounting_complete
1429        },
1430        XmlExternalEntityReviewOutcome::ControlCallbackObserved => {
1431            facts.preflight_clean
1432                && facts.control_callback_observed
1433                && facts.target_request_count > 0
1434                && !facts.target_complete
1435                && !facts.target_accounting_complete
1436                && !facts.provider_accounting_complete
1437        },
1438        XmlExternalEntityReviewOutcome::NoCallback => complete_lifecycle && no_callbacks,
1439        XmlExternalEntityReviewOutcome::CandidateOnly => {
1440            complete_lifecycle
1441                && facts.candidate_callback_observed
1442                && !facts.replay_callback_observed
1443                && !facts.event_identities_distinct
1444        },
1445        XmlExternalEntityReviewOutcome::ReplayOnly => {
1446            complete_lifecycle
1447                && !facts.candidate_callback_observed
1448                && facts.replay_callback_observed
1449                && !facts.event_identities_distinct
1450        },
1451        XmlExternalEntityReviewOutcome::CorrelationMismatch => {
1452            facts.preflight_clean
1453                && !facts.control_callback_observed
1454                && (facts.candidate_callback_observed || facts.replay_callback_observed)
1455                && !facts.event_identities_distinct
1456        },
1457        XmlExternalEntityReviewOutcome::CleanupIncomplete => {
1458            facts.preflight_clean && !facts.control_callback_observed && !facts.cleanup_verified
1459        },
1460        XmlExternalEntityReviewOutcome::Cancelled
1461        | XmlExternalEntityReviewOutcome::BudgetExhausted
1462        | XmlExternalEntityReviewOutcome::Incomplete => stopped_lifecycle,
1463        XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved => {
1464            complete_positive
1465        },
1466    };
1467    if !outcome_facts_match {
1468        return Err(XmlExternalEntityAuditError::InvalidOutcome);
1469    }
1470    if facts.item_projected != positive_outcome {
1471        return Err(XmlExternalEntityAuditError::InvalidProjection);
1472    }
1473    Ok(())
1474}
1475
1476fn digest_parts(domain: &[u8], parts: &[&[u8]]) -> [u8; 32] {
1477    let mut hasher = Sha256::new();
1478    hasher.update(domain);
1479    for part in parts {
1480        hasher.update((part.len() as u64).to_be_bytes());
1481        hasher.update(part);
1482    }
1483    hasher.finalize().into()
1484}
1485
1486fn hex(bytes: [u8; 32]) -> String {
1487    const DIGITS: &[u8; 16] = b"0123456789abcdef";
1488    let mut output = String::with_capacity(64);
1489    for byte in bytes {
1490        output.push(DIGITS[(byte >> 4) as usize] as char);
1491        output.push(DIGITS[(byte & 0x0f) as usize] as char);
1492    }
1493    output
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1499
1500    use super::*;
1501
1502    const SESSION: &str = "AQEBAQEBAQEBAQEBAQEBAQ";
1503    const CONTROL_CALLBACK: &str = "AgICAgICAgICAgICAgICAg";
1504    const CANDIDATE_CALLBACK: &str = "AwMDAwMDAwMDAwMDAwMDAw";
1505    const REPLAY_CALLBACK: &str = "BAQEBAQEBAQEBAQEBAQEBA";
1506
1507    fn policy_source(endpoint: &str, provider: &str) -> Vec<u8> {
1508        format!(
1509            "schema = \"{XML_EXTERNAL_ENTITY_REVIEW_POLICY_SCHEMA}\"\n\
1510endpoint = \"{endpoint}\"\n\
1511provider_origin = \"{provider}\"\n\
1512method = \"{XML_EXTERNAL_ENTITY_REVIEW_METHOD}\"\n\
1513media_type = \"{XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE}\"\n\
1514acknowledge_xml_post = true\n\
1515acknowledge_external_interaction = true\n\
1516acknowledge_disposable_test_endpoint = true\n\
1517polls_per_leg = 2\n\
1518poll_interval_ms = 500\n\
1519lifetime_ms = 10000\n"
1520        )
1521        .into_bytes()
1522    }
1523
1524    fn production_policy() -> XmlExternalEntityReviewPolicy {
1525        XmlExternalEntityReviewPolicy::parse_toml(
1526            &Url::parse("https://app.example.test/application/").unwrap(),
1527            &policy_source(
1528                "https://app.example.test/application/fixtures/xml",
1529                "https://oast.example.test/",
1530            ),
1531        )
1532        .unwrap()
1533    }
1534
1535    fn loopback_policy() -> XmlExternalEntityReviewPolicy {
1536        let provider_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 39091);
1537        XmlExternalEntityReviewPolicy::for_owned_loopback(
1538            Url::parse("http://127.0.0.1:39090/application/").unwrap(),
1539            Url::parse("http://127.0.0.1:39090/application/fixtures/xml").unwrap(),
1540            PublicOrigin::from_test_loopback(provider_address).unwrap(),
1541            2,
1542            500,
1543            10_000,
1544        )
1545        .unwrap()
1546    }
1547
1548    fn callback(callback: &str) -> String {
1549        format!("http://127.0.0.1:39091/c/{SESSION}/{callback}")
1550    }
1551
1552    #[test]
1553    fn wire_vocabulary_and_derived_callback_counts_are_total() {
1554        let policy = production_policy();
1555        assert_eq!(
1556            format!("{}", policy.policy_id()),
1557            policy.policy_id().to_wire()
1558        );
1559        assert_eq!(
1560            policy.policy_revision(),
1561            XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM
1562        );
1563        assert_eq!(policy.method().as_str(), XML_EXTERNAL_ENTITY_REVIEW_METHOD);
1564        assert_eq!(
1565            policy.media_type().as_str(),
1566            XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE
1567        );
1568        assert_eq!(
1569            XmlExternalEntityExecutionMode::Production.as_str(),
1570            "production"
1571        );
1572        assert_eq!(
1573            XmlExternalEntityExecutionMode::OwnedNumericLoopbackTest.as_str(),
1574            "owned_numeric_loopback_test"
1575        );
1576
1577        let outcomes = [
1578            (XmlExternalEntityReviewOutcome::NotEligible, "not_eligible"),
1579            (
1580                XmlExternalEntityReviewOutcome::ControlIncomplete,
1581                "control_incomplete",
1582            ),
1583            (
1584                XmlExternalEntityReviewOutcome::PreflightContaminated,
1585                "preflight_contaminated",
1586            ),
1587            (
1588                XmlExternalEntityReviewOutcome::ControlCallbackObserved,
1589                "control_callback_observed",
1590            ),
1591            (XmlExternalEntityReviewOutcome::NoCallback, "no_callback"),
1592            (
1593                XmlExternalEntityReviewOutcome::CandidateOnly,
1594                "candidate_only",
1595            ),
1596            (XmlExternalEntityReviewOutcome::ReplayOnly, "replay_only"),
1597            (
1598                XmlExternalEntityReviewOutcome::CorrelationMismatch,
1599                "correlation_mismatch",
1600            ),
1601            (
1602                XmlExternalEntityReviewOutcome::CleanupIncomplete,
1603                "cleanup_incomplete",
1604            ),
1605            (XmlExternalEntityReviewOutcome::Cancelled, "cancelled"),
1606            (
1607                XmlExternalEntityReviewOutcome::BudgetExhausted,
1608                "budget_exhausted",
1609            ),
1610            (XmlExternalEntityReviewOutcome::Incomplete, "incomplete"),
1611            (
1612                XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved,
1613                "repeated_external_entity_resolution_observed",
1614            ),
1615        ];
1616        for (outcome, expected) in outcomes {
1617            assert_eq!(outcome.as_str(), expected);
1618        }
1619        assert_eq!(
1620            XmlExternalEntityMaximumDisposition::NeedsReview.as_str(),
1621            "needs_review"
1622        );
1623        assert_eq!(
1624            XmlExternalEntityOperationStatus::NotPerformed.as_str(),
1625            "not_performed"
1626        );
1627
1628        let positive =
1629            XmlExternalEntityReviewAudit::from_runtime(&policy, positive_facts()).unwrap();
1630        assert_eq!(
1631            positive.policy_revision(),
1632            XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM
1633        );
1634        assert_eq!(
1635            positive.execution_mode(),
1636            XmlExternalEntityExecutionMode::Production
1637        );
1638        assert_eq!(positive.distinct_event_count(), 2);
1639        assert!(positive.provider_complete());
1640
1641        let mut candidate_only = positive_facts();
1642        candidate_only.outcome = XmlExternalEntityReviewOutcome::CandidateOnly;
1643        candidate_only.replay_callback_observed = false;
1644        candidate_only.event_identities_distinct = false;
1645        candidate_only.item_projected = false;
1646        let candidate_only =
1647            XmlExternalEntityReviewAudit::from_runtime(&policy, candidate_only).unwrap();
1648        assert_eq!(candidate_only.distinct_event_count(), 1);
1649
1650        let empty = XmlExternalEntityReviewAudit::from_runtime(
1651            &policy,
1652            XmlExternalEntityAuditFacts {
1653                outcome: XmlExternalEntityReviewOutcome::NotEligible,
1654                target_request_count: 0,
1655                provider_request_count: 0,
1656                active_verification_count: 0,
1657                target_request_body_bytes: 0,
1658                target_response_bytes: 0,
1659                target_complete: false,
1660                preflight_clean: false,
1661                control_callback_observed: false,
1662                candidate_callback_observed: false,
1663                replay_callback_observed: false,
1664                callback_targets_distinct: false,
1665                event_identities_distinct: false,
1666                cleanup_verified: false,
1667                target_accounting_complete: false,
1668                provider_accounting_complete: false,
1669                item_projected: false,
1670            },
1671        )
1672        .unwrap();
1673        assert_eq!(empty.distinct_event_count(), 0);
1674        assert!(!empty.provider_complete());
1675    }
1676
1677    fn positive_facts() -> XmlExternalEntityAuditFacts {
1678        XmlExternalEntityAuditFacts {
1679            outcome: XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved,
1680            target_request_count: XML_EXTERNAL_ENTITY_TARGET_REQUESTS as u8,
1681            provider_request_count: MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS as u8,
1682            active_verification_count: XML_EXTERNAL_ENTITY_ACTIVE_VERIFICATIONS as u8,
1683            target_request_body_bytes: 768,
1684            target_response_bytes: 96,
1685            target_complete: true,
1686            preflight_clean: true,
1687            control_callback_observed: false,
1688            candidate_callback_observed: true,
1689            replay_callback_observed: true,
1690            callback_targets_distinct: true,
1691            event_identities_distinct: true,
1692            cleanup_verified: true,
1693            target_accounting_complete: true,
1694            provider_accounting_complete: true,
1695            item_projected: true,
1696        }
1697    }
1698
1699    #[test]
1700    fn strict_policy_binds_exact_post_media_origin_and_application() {
1701        let policy = production_policy();
1702        let selected_application = Url::parse("https://app.example.test/application/").unwrap();
1703        let broader_application = Url::parse("https://app.example.test/").unwrap();
1704        assert!(policy.is_bound_to_application(&selected_application));
1705        assert!(!policy.is_bound_to_application(&broader_application));
1706        let broader_policy = XmlExternalEntityReviewPolicy::parse_toml(
1707            &broader_application,
1708            &policy_source(
1709                "https://app.example.test/application/fixtures/xml",
1710                "https://oast.example.test/",
1711            ),
1712        )
1713        .unwrap();
1714        assert_ne!(policy.policy_id(), broader_policy.policy_id());
1715        assert_eq!(policy.method(), XmlExternalEntityHttpMethod::Post);
1716        assert_eq!(
1717            policy.media_type(),
1718            XmlExternalEntityMediaType::ApplicationXmlUtf8
1719        );
1720        assert_eq!(
1721            policy.execution_mode(),
1722            XmlExternalEntityExecutionMode::Production
1723        );
1724        assert_eq!(policy.polls_per_leg(), 2);
1725        assert_eq!(policy.poll_interval_ms(), 500);
1726        assert_eq!(policy.lifetime_ms(), 10_000);
1727        assert!(policy
1728            .policy_id()
1729            .to_wire()
1730            .starts_with("xml-external-entity-policy-sha256:"));
1731        let rendered = format!("{policy:?}");
1732        assert!(!rendered.contains("app.example.test"));
1733        assert!(!rendered.contains("oast.example.test"));
1734        assert!(!rendered.contains("/fixtures/xml"));
1735    }
1736
1737    #[test]
1738    fn policy_rejects_authority_broadening_mutations() {
1739        let assessment = Url::parse("https://app.example.test/application/").unwrap();
1740        let base = String::from_utf8(policy_source(
1741            "https://app.example.test/application/fixtures/xml",
1742            "https://oast.example.test/",
1743        ))
1744        .unwrap();
1745        let mutations = [
1746            base.replace(
1747                XML_EXTERNAL_ENTITY_REVIEW_POLICY_SCHEMA,
1748                "security.xml-external-entity-review-policy/v2",
1749            ),
1750            base.replace("method = \"POST\"", "method = \"PUT\""),
1751            base.replace(
1752                "media_type = \"application/xml; charset=utf-8\"",
1753                "media_type = \"text/xml\"",
1754            ),
1755            base.replace(
1756                "acknowledge_xml_post = true",
1757                "acknowledge_xml_post = false",
1758            ),
1759            base.replace(
1760                "acknowledge_external_interaction = true",
1761                "acknowledge_external_interaction = false",
1762            ),
1763            base.replace(
1764                "acknowledge_disposable_test_endpoint = true",
1765                "acknowledge_disposable_test_endpoint = false",
1766            ),
1767            base.replace(
1768                "https://app.example.test/application/fixtures/xml",
1769                "https://other.example.test/application/fixtures/xml",
1770            ),
1771            base.replace(
1772                "https://app.example.test/application/fixtures/xml",
1773                "https://app.example.test/sibling/fixtures/xml",
1774            ),
1775            base.replace(
1776                "https://app.example.test/application/fixtures/xml",
1777                "https://app.example.test/application/fixtures/xml?mode=test",
1778            ),
1779            base.replace(
1780                "https://app.example.test/application/fixtures/xml",
1781                "https://user@app.example.test/application/fixtures/xml",
1782            ),
1783            base.replace(
1784                "https://app.example.test/application/fixtures/xml",
1785                "http://app.example.test/application/fixtures/xml",
1786            ),
1787            base.replace(
1788                "https://app.example.test/application/fixtures/xml",
1789                "https://127.0.0.1/application/fixtures/xml",
1790            ),
1791            format!("{base}unknown = true\n"),
1792        ];
1793        for mutation in mutations {
1794            assert!(
1795                XmlExternalEntityReviewPolicy::parse_toml(&assessment, mutation.as_bytes())
1796                    .is_err(),
1797                "authority-broadening mutation unexpectedly passed"
1798            );
1799        }
1800
1801        let same_provider = base.replace("https://oast.example.test/", "https://app.example.test/");
1802        assert_eq!(
1803            XmlExternalEntityReviewPolicy::parse_toml(&assessment, same_provider.as_bytes())
1804                .unwrap_err(),
1805            XmlExternalEntityReviewPolicyError::ProviderOriginMatchesTarget
1806        );
1807    }
1808
1809    #[test]
1810    fn policy_endpoint_rejects_raw_normalization_and_unsafe_separators() {
1811        let assessment = Url::parse("https://app.example.test/application/").unwrap();
1812        let canonical = "https://app.example.test/application/fixtures/xml";
1813        assert!(XmlExternalEntityReviewPolicy::parse_toml(
1814            &assessment,
1815            &policy_source(canonical, "https://oast.example.test/")
1816        )
1817        .is_ok());
1818
1819        for ambiguous in [
1820            "https://app.example.test/application/fixtures/./xml",
1821            "https://app.example.test/application/fixtures/safe/../xml",
1822            "https://app.example.test/application/fixtures/safe/%2e%2e/xml",
1823            "https://app.example.test/application/fixtures/%2f/xml",
1824            "HTTPS://APP.EXAMPLE.TEST:443/application/fixtures/xml",
1825        ] {
1826            assert_eq!(
1827                XmlExternalEntityReviewPolicy::parse_toml(
1828                    &assessment,
1829                    &policy_source(ambiguous, "https://oast.example.test/")
1830                )
1831                .unwrap_err(),
1832                XmlExternalEntityReviewPolicyError::InvalidEndpoint,
1833                "normalized endpoint spelling was admitted: {ambiguous}"
1834            );
1835        }
1836
1837        let backslash = String::from_utf8(policy_source(canonical, "https://oast.example.test/"))
1838            .unwrap()
1839            .replace(
1840                &format!("endpoint = \"{canonical}\""),
1841                r"endpoint = 'https://app.example.test/application/fixtures\xml'",
1842            );
1843        assert_eq!(
1844            XmlExternalEntityReviewPolicy::parse_toml(&assessment, backslash.as_bytes())
1845                .unwrap_err(),
1846            XmlExternalEntityReviewPolicyError::InvalidEndpoint
1847        );
1848    }
1849
1850    #[test]
1851    fn policy_limits_and_source_ceiling_fail_closed() {
1852        let assessment = Url::parse("https://app.example.test/application/").unwrap();
1853        let base = String::from_utf8(policy_source(
1854            "https://app.example.test/application/fixtures/xml",
1855            "https://oast.example.test/",
1856        ))
1857        .unwrap();
1858        for mutation in [
1859            base.replace("polls_per_leg = 2", "polls_per_leg = 0"),
1860            base.replace("polls_per_leg = 2", "polls_per_leg = 3"),
1861            base.replace("poll_interval_ms = 500", "poll_interval_ms = 249"),
1862            base.replace("poll_interval_ms = 500", "poll_interval_ms = 2001"),
1863            base.replace("lifetime_ms = 10000", "lifetime_ms = 4999"),
1864            base.replace("lifetime_ms = 10000", "lifetime_ms = 30001"),
1865            base.replace("poll_interval_ms = 500", "poll_interval_ms = 2000"),
1866        ] {
1867            assert_eq!(
1868                XmlExternalEntityReviewPolicy::parse_toml(&assessment, mutation.as_bytes())
1869                    .unwrap_err(),
1870                XmlExternalEntityReviewPolicyError::InvalidLimits
1871            );
1872        }
1873        assert_eq!(
1874            XmlExternalEntityReviewPolicy::parse_toml(
1875                &assessment,
1876                &vec![b'a'; MAX_XML_EXTERNAL_ENTITY_REVIEW_POLICY_BYTES + 1]
1877            )
1878            .unwrap_err(),
1879            XmlExternalEntityReviewPolicyError::PolicyTooLarge
1880        );
1881    }
1882
1883    #[test]
1884    fn numeric_loopback_requires_the_owned_test_constructor() {
1885        let policy = loopback_policy();
1886        assert_eq!(
1887            policy.execution_mode(),
1888            XmlExternalEntityExecutionMode::OwnedNumericLoopbackTest
1889        );
1890        assert_eq!(
1891            XmlExternalEntityReviewPolicy::parse_toml(
1892                &Url::parse("http://127.0.0.1:39090/application/").unwrap(),
1893                &policy_source(
1894                    "http://127.0.0.1:39090/application/fixtures/xml",
1895                    "http://127.0.0.1:39091/"
1896                )
1897            )
1898            .unwrap_err(),
1899            XmlExternalEntityReviewPolicyError::InvalidAssessmentTarget
1900        );
1901    }
1902
1903    #[test]
1904    fn administrator_token_is_bounded_move_only_and_redacted() {
1905        let token =
1906            XmlExternalEntityAdminToken::new(vec![b'a'; MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES])
1907                .unwrap();
1908        assert_eq!(
1909            format!("{token:?}"),
1910            "XmlExternalEntityAdminToken(<redacted>)"
1911        );
1912        assert_eq!(
1913            token.into_bytes(),
1914            vec![b'a'; MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES]
1915        );
1916        for invalid in [
1917            vec![],
1918            vec![b'a'; MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES - 1],
1919            vec![b'a'; MAX_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES + 1],
1920            vec![b' '; MIN_XML_EXTERNAL_ENTITY_ADMIN_TOKEN_BYTES],
1921        ] {
1922            assert_eq!(
1923                XmlExternalEntityAdminToken::new(invalid).unwrap_err(),
1924                XmlExternalEntityAdminTokenError::Invalid
1925            );
1926        }
1927    }
1928
1929    #[test]
1930    fn canonical_plan_has_one_external_general_entity_and_distinct_roles() {
1931        let policy = loopback_policy();
1932        let plan = XmlExternalEntityDocumentPlan::from_callback_strings(
1933            &policy,
1934            &callback(CONTROL_CALLBACK),
1935            &callback(CANDIDATE_CALLBACK),
1936            &callback(REPLAY_CALLBACK),
1937        )
1938        .unwrap();
1939        let control = std::str::from_utf8(
1940            plan.document(XmlExternalEntityDocumentRole::Control)
1941                .as_bytes(),
1942        )
1943        .unwrap();
1944        let candidate = std::str::from_utf8(
1945            plan.document(XmlExternalEntityDocumentRole::Candidate)
1946                .as_bytes(),
1947        )
1948        .unwrap();
1949        let replay = std::str::from_utf8(
1950            plan.document(XmlExternalEntityDocumentRole::Replay)
1951                .as_bytes(),
1952        )
1953        .unwrap();
1954
1955        for body in [control, candidate, replay] {
1956            assert_eq!(body.matches("<!ENTITY ").count(), 1);
1957            assert!(body.contains("<!ENTITY termivar_external SYSTEM \""));
1958            assert!(!body.contains("<!ENTITY %"));
1959            assert!(!body.contains(" PUBLIC "));
1960            assert!(!body.contains("XInclude"));
1961            assert!(body.len() <= MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES);
1962        }
1963        assert!(control.contains("role=\"control\""));
1964        assert!(!control.contains("&termivar_external;"));
1965        assert!(candidate.contains("role=\"candidate\""));
1966        assert_eq!(candidate.matches("&termivar_external;").count(), 1);
1967        assert!(replay.contains("role=\"replay\""));
1968        assert_eq!(replay.matches("&termivar_external;").count(), 1);
1969        assert_ne!(control, candidate);
1970        assert_ne!(candidate, replay);
1971    }
1972
1973    #[test]
1974    fn callback_targets_are_exact_native_routes_and_pairwise_distinct() {
1975        let policy = loopback_policy();
1976        assert_eq!(
1977            XmlExternalEntityDocumentPlan::from_callback_strings(
1978                &policy,
1979                &callback(CONTROL_CALLBACK),
1980                &callback(CONTROL_CALLBACK),
1981                &callback(REPLAY_CALLBACK),
1982            )
1983            .err()
1984            .unwrap(),
1985            XmlExternalEntityDocumentError::CallbackIdentityConflict
1986        );
1987        for invalid in [
1988            "http://127.0.0.1:39092/c/AQEBAQEBAQEBAQEBAQEBAQ/AgICAgICAgICAgICAgICAg",
1989            "http://127.0.0.1:39091/not-a-callback",
1990            "http://127.0.0.1:39091/c/AQEBAQEBAQEBAQEBAQEBAQ/AgICAgICAgICAgICAgICAg?x=1",
1991            "https://127.0.0.1:39091/c/AQEBAQEBAQEBAQEBAQEBAQ/AgICAgICAgICAgICAgICAg",
1992        ] {
1993            assert_eq!(
1994                XmlExternalEntityDocumentPlan::from_callback_strings(
1995                    &policy,
1996                    invalid,
1997                    &callback(CANDIDATE_CALLBACK),
1998                    &callback(REPLAY_CALLBACK),
1999                )
2000                .err()
2001                .unwrap(),
2002                XmlExternalEntityDocumentError::InvalidCallbackTarget
2003            );
2004        }
2005    }
2006
2007    #[test]
2008    fn xml_attribute_escaping_is_canonical_and_body_bound_is_enforced() {
2009        assert_eq!(
2010            escape_xml_attribute("a&<>\"'z"),
2011            "a&amp;&lt;&gt;&quot;&apos;z"
2012        );
2013        assert_eq!(
2014            build_document(
2015                &loopback_policy(),
2016                XmlExternalEntityDocumentRole::Candidate,
2017                &"a".repeat(MAX_XML_EXTERNAL_ENTITY_DOCUMENT_BYTES)
2018            )
2019            .err()
2020            .unwrap(),
2021            XmlExternalEntityDocumentError::DocumentTooLarge
2022        );
2023    }
2024
2025    #[test]
2026    fn audit_positive_outcome_enforces_claim_and_lifecycle_invariants() {
2027        let policy = production_policy();
2028        let audit = XmlExternalEntityReviewAudit::from_runtime(&policy, positive_facts()).unwrap();
2029        assert_eq!(audit.schema(), XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA);
2030        assert_eq!(audit.algorithm(), XML_EXTERNAL_ENTITY_REVIEW_ALGORITHM);
2031        assert_eq!(audit.action_id(), XML_EXTERNAL_ENTITY_REVIEW_ACTION_ID);
2032        assert_eq!(
2033            audit.capability_id(),
2034            XML_EXTERNAL_ENTITY_REVIEW_CAPABILITY_ID
2035        );
2036        assert_eq!(audit.method(), XML_EXTERNAL_ENTITY_REVIEW_METHOD);
2037        assert_eq!(audit.media_type(), XML_EXTERNAL_ENTITY_REVIEW_MEDIA_TYPE);
2038        assert_eq!(
2039            audit.control_model(),
2040            XML_EXTERNAL_ENTITY_REVIEW_CONTROL_MODEL
2041        );
2042        assert_eq!(
2043            audit.outcome(),
2044            XmlExternalEntityReviewOutcome::RepeatedExternalEntityResolutionObserved
2045        );
2046        assert_eq!(audit.target_request_body_bytes(), 768);
2047        assert_eq!(audit.target_response_bytes(), 96);
2048        assert!(audit.target_complete());
2049        assert!(audit.item_projected());
2050        assert_eq!(
2051            audit.claim_limits().maximum_disposition(),
2052            XmlExternalEntityMaximumDisposition::NeedsReview
2053        );
2054        assert_eq!(
2055            audit.semantic_effect(),
2056            XmlExternalEntityOperationStatus::NotPerformed
2057        );
2058        assert_eq!(
2059            audit.impact_validation(),
2060            XmlExternalEntityOperationStatus::NotPerformed
2061        );
2062        let wire = serde_json::to_string(&audit).unwrap();
2063        assert!(wire.contains(XML_EXTERNAL_ENTITY_REVIEW_AUDIT_SCHEMA));
2064        assert!(wire.contains("\"semantic_effect\":\"not_performed\""));
2065        assert!(wire.contains("\"impact_validation\":\"not_performed\""));
2066        assert!(!wire.contains("app.example.test"));
2067        assert!(!wire.contains("oast.example.test"));
2068        assert!(!format!("{audit:?}").contains("fixtures/xml"));
2069
2070        let mut contaminated = positive_facts();
2071        contaminated.control_callback_observed = true;
2072        assert_eq!(
2073            XmlExternalEntityReviewAudit::from_runtime(&policy, contaminated).unwrap_err(),
2074            XmlExternalEntityAuditError::InvalidOutcome
2075        );
2076
2077        let mut incomplete = positive_facts();
2078        incomplete.target_complete = false;
2079        assert_eq!(
2080            XmlExternalEntityReviewAudit::from_runtime(&policy, incomplete).unwrap_err(),
2081            XmlExternalEntityAuditError::InvalidCorrelation
2082        );
2083
2084        let mut over_budget = positive_facts();
2085        over_budget.provider_request_count = MAX_XML_EXTERNAL_ENTITY_PROVIDER_REQUESTS as u8 + 1;
2086        assert_eq!(
2087            XmlExternalEntityReviewAudit::from_runtime(&policy, over_budget).unwrap_err(),
2088            XmlExternalEntityAuditError::InvalidCounts
2089        );
2090
2091        let mut oversized_complete_response = positive_facts();
2092        oversized_complete_response.target_response_bytes =
2093            u64::try_from(XML_EXTERNAL_ENTITY_TARGET_REQUESTS).unwrap()
2094                * MAX_XML_EXTERNAL_ENTITY_TARGET_RESPONSE_BYTES
2095                + 1;
2096        assert_eq!(
2097            XmlExternalEntityReviewAudit::from_runtime(&policy, oversized_complete_response)
2098                .unwrap_err(),
2099            XmlExternalEntityAuditError::InvalidCounts
2100        );
2101    }
2102
2103    #[test]
2104    fn non_positive_audit_cannot_project_an_item() {
2105        let policy = production_policy();
2106        let mut preflight = positive_facts();
2107        preflight.outcome = XmlExternalEntityReviewOutcome::PreflightContaminated;
2108        preflight.target_request_count = 0;
2109        preflight.target_request_body_bytes = 0;
2110        preflight.target_response_bytes = 0;
2111        preflight.target_complete = false;
2112        preflight.target_accounting_complete = false;
2113        preflight.provider_request_count = 6;
2114        preflight.active_verification_count = 0;
2115        preflight.provider_accounting_complete = false;
2116        preflight.preflight_clean = false;
2117        preflight.candidate_callback_observed = true;
2118        preflight.replay_callback_observed = false;
2119        preflight.event_identities_distinct = false;
2120        preflight.item_projected = false;
2121        let audit = XmlExternalEntityReviewAudit::from_runtime(&policy, preflight).unwrap();
2122        assert_eq!(
2123            audit.outcome(),
2124            XmlExternalEntityReviewOutcome::PreflightContaminated
2125        );
2126
2127        let mut facts = positive_facts();
2128        facts.outcome = XmlExternalEntityReviewOutcome::CandidateOnly;
2129        facts.replay_callback_observed = false;
2130        facts.event_identities_distinct = false;
2131        facts.item_projected = false;
2132        let audit = XmlExternalEntityReviewAudit::from_runtime(&policy, facts).unwrap();
2133        assert_eq!(
2134            audit.outcome(),
2135            XmlExternalEntityReviewOutcome::CandidateOnly
2136        );
2137        assert!(!audit.item_projected());
2138
2139        let mut invalid = positive_facts();
2140        invalid.outcome = XmlExternalEntityReviewOutcome::CandidateOnly;
2141        invalid.replay_callback_observed = false;
2142        invalid.event_identities_distinct = false;
2143        assert_eq!(
2144            XmlExternalEntityReviewAudit::from_runtime(&policy, invalid).unwrap_err(),
2145            XmlExternalEntityAuditError::InvalidProjection
2146        );
2147    }
2148
2149    #[test]
2150    fn audit_rejects_cross_field_lifecycle_mutations_for_negative_outcomes() {
2151        let policy = production_policy();
2152
2153        let mut wrong_active_count = positive_facts();
2154        wrong_active_count.outcome = XmlExternalEntityReviewOutcome::CandidateOnly;
2155        wrong_active_count.replay_callback_observed = false;
2156        wrong_active_count.event_identities_distinct = false;
2157        wrong_active_count.item_projected = false;
2158        wrong_active_count.active_verification_count = 0;
2159        assert_eq!(
2160            XmlExternalEntityReviewAudit::from_runtime(&policy, wrong_active_count).unwrap_err(),
2161            XmlExternalEntityAuditError::InvalidCorrelation
2162        );
2163
2164        let mut impossible_target_accounting = positive_facts();
2165        impossible_target_accounting.outcome = XmlExternalEntityReviewOutcome::Incomplete;
2166        impossible_target_accounting.target_complete = false;
2167        impossible_target_accounting.item_projected = false;
2168        assert_eq!(
2169            XmlExternalEntityReviewAudit::from_runtime(&policy, impossible_target_accounting)
2170                .unwrap_err(),
2171            XmlExternalEntityAuditError::InvalidCorrelation
2172        );
2173
2174        let mut unbound_callback = positive_facts();
2175        unbound_callback.outcome = XmlExternalEntityReviewOutcome::CorrelationMismatch;
2176        unbound_callback.callback_targets_distinct = false;
2177        unbound_callback.event_identities_distinct = false;
2178        unbound_callback.item_projected = false;
2179        assert_eq!(
2180            XmlExternalEntityReviewAudit::from_runtime(&policy, unbound_callback).unwrap_err(),
2181            XmlExternalEntityAuditError::InvalidCorrelation
2182        );
2183
2184        let mut fabricated_complete_provider = positive_facts();
2185        fabricated_complete_provider.outcome = XmlExternalEntityReviewOutcome::CandidateOnly;
2186        fabricated_complete_provider.provider_request_count = 8;
2187        fabricated_complete_provider.replay_callback_observed = false;
2188        fabricated_complete_provider.event_identities_distinct = false;
2189        fabricated_complete_provider.item_projected = false;
2190        assert_eq!(
2191            XmlExternalEntityReviewAudit::from_runtime(&policy, fabricated_complete_provider)
2192                .unwrap_err(),
2193            XmlExternalEntityAuditError::InvalidCorrelation
2194        );
2195
2196        let mut stopped_with_complete_accounting = positive_facts();
2197        stopped_with_complete_accounting.outcome = XmlExternalEntityReviewOutcome::Incomplete;
2198        stopped_with_complete_accounting.cleanup_verified = false;
2199        stopped_with_complete_accounting.item_projected = false;
2200        assert_eq!(
2201            XmlExternalEntityReviewAudit::from_runtime(&policy, stopped_with_complete_accounting)
2202                .unwrap_err(),
2203            XmlExternalEntityAuditError::InvalidOutcome
2204        );
2205    }
2206}