From a493b91045ebb2fd085997c8f1fed102a9f4e8cf Mon Sep 17 00:00:00 2001 From: VerifiedOrganic Date: Sat, 15 Aug 2026 17:41:25 -0600 Subject: [PATCH 1/2] feat(gtpv2c): add finite S2b F-TEID receive policy Signed-off-by: VerifiedOrganic --- CHANGELOG.md | 9 + crates/opc-proto-gtpv2c/CONFORMANCE.md | 14 +- crates/opc-proto-gtpv2c/README.md | 34 ++ crates/opc-proto-gtpv2c/src/lib.rs | 42 +- crates/opc-proto-gtpv2c/src/s2b.rs | 533 ++++++++++++++---- .../tests/s2b_profile_builders.rs | 157 ++++++ crates/opc-proto-gtpv2c/tests/s2b_typed.rs | 516 ++++++++++++++++- docs/implementation-status.md | 2 +- 8 files changed, 1162 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f3cc55..6a8c21cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Finite S2b Create Session Response F-TEID receive policy — + `opc-proto-gtpv2c`:** + `S2bCreateSessionResponseReceivePolicy` and the one-shot + `decode_create_session_response_summary_with_receive_policy` let callers + independently add standardized S5/S8 PGW control type 7 and user-plane type + 5 to the strict default S2b role sets `{32}` and `{33}`. The copied finite + policy binds ProcedureAware first-occurrence validation to typed projection; + existing decode/projection APIs and canonical builders remain strict, and + errors and Debug output remain value-free. - **Durable grouped XFRM object roster transaction — `opc-ipsec-xfrm`:** `LinuxXfrmBackend::bind_current_network_namespace_with_object_roster_recovery` and the opt-in migration constructor diff --git a/crates/opc-proto-gtpv2c/CONFORMANCE.md b/crates/opc-proto-gtpv2c/CONFORMANCE.md index 99257196..0129e084 100644 --- a/crates/opc-proto-gtpv2c/CONFORMANCE.md +++ b/crates/opc-proto-gtpv2c/CONFORMANCE.md @@ -168,7 +168,13 @@ failures and must cover at least these rules: 1/interface type 32, and Bearer Context for accepted responses (Cause 16/17). The control endpoint requires a non-zero TEID and at least one address; instance-0 Sender F-TEID is unexpected on this S2b response profile and is - discarded. Rejected responses may expose Cause-only summaries. + discarded. Rejected responses may expose Cause-only summaries. The explicit + one-shot receive policy may independently add only S5/S8 PGW control type 7 + and PGW user-plane type 5 to the strict role sets `{32}` and `{33}`. The + policy is copied for one decode and binds validation and typed projection to + the same first retained role occurrence. It cannot admit arbitrary interface + values or affect another profile; no-policy APIs and canonical builders stay + strict S2b and emit only control type 32 and user-plane type 33. - S2b Modify Bearer Request is the UE-initiated IPsec tunnel-update profile. It requires a non-zero header TEID but no mandatory IE. WLAN Location Information (TWAN Identifier instance 0) and WLAN Location Timestamp (TWAN @@ -245,7 +251,11 @@ failures and must cover at least these rules: grouped scope is retained, later occurrences are ignored, and bounded `S2bReceiveDiagnostics` records only type, instance, scope/depth, first offset, and a saturated duplicate count. A malformed or semantically invalid - first value remains an error and cannot be repaired by a later value. + first value remains an error and cannot be repaired by a later value. For + Create Session Response nested F-TEIDs, this rule applies per `(type, + instance)` singleton key: distinct table-defined instances remain eligible + for their distinct roles, while a later duplicate at the same instance can + never repair the retained first occurrence. - ProcedureAware receive classifies every crate-known typed/control IE key against one message grammar keyed by procedure, direction, and exact enclosing Bearer Context instance before decoding its value. Unexpected diff --git a/crates/opc-proto-gtpv2c/README.md b/crates/opc-proto-gtpv2c/README.md index 115682e0..a75ccf52 100644 --- a/crates/opc-proto-gtpv2c/README.md +++ b/crates/opc-proto-gtpv2c/README.md @@ -601,6 +601,40 @@ let response = S2bCreateSessionAcceptedResponse { }; ``` +Create Session Response receive decoding remains strict by default: the PGW +control role accepts only interface type 32 and the PGW user-plane role only +type 33. A caller that has already made its own interworking decision can use +the one-shot `decode_create_session_response_summary_with_receive_policy` +helper with `S2bCreateSessionResponseReceivePolicy` to independently add the +standardized S5/S8 control type 7, user-plane type 5, or both. The policy has no +arbitrary interface-type constructor, is copied for one decode, and is applied +to the same first retained singleton used by the returned typed summary. It +does not affect `S2bMessage::decode`, the no-policy summary helpers, any other +GTPv2-C procedure, or canonical builders; those remain strict S2b. + +```rust +use opc_proto_gtpv2c::{ + decode_create_session_response_summary_with_receive_policy, + S2bCreateSessionResponseReceivePolicy, +}; +use opc_protocol::{DecodeContext, ValidationLevel}; + +# let response_bytes: &[u8] = &[]; +let receive_policy = S2bCreateSessionResponseReceivePolicy::STRICT + .allow_s5_s8_pgw_control() + .allow_s5_s8_pgw_user_plane(); +let receive_context = DecodeContext { + validation_level: ValidationLevel::ProcedureAware, + ..DecodeContext::default() +}; +let summary = decode_create_session_response_summary_with_receive_policy( + response_bytes, + receive_context, + receive_policy, +); +# let _ = summary; +``` + The former loose Update Bearer shell with a single `bearer_context` has been replaced by the strict dedicated-bearer API. Construct `S2bUpdateBearerRequest` with mandatory `apn_ambr` and one to fifteen diff --git a/crates/opc-proto-gtpv2c/src/lib.rs b/crates/opc-proto-gtpv2c/src/lib.rs index 808e40d5..ec6d2916 100644 --- a/crates/opc-proto-gtpv2c/src/lib.rs +++ b/crates/opc-proto-gtpv2c/src/lib.rs @@ -163,32 +163,34 @@ pub use pco::{ }; #[allow(deprecated)] pub use s2b::{ - decode_create_session_response_summary, decode_echo_message_evidence, is_s2b_message_type, - s2b_create_session_accepted_response, s2b_create_session_rejected_response, - s2b_create_session_request, s2b_delete_session_request, s2b_delete_session_response, - s2b_echo_request, s2b_echo_response, s2b_modify_bearer_request, s2b_modify_bearer_response, - s2b_ue_ipsec_tunnel_update_request, CreateSessionAcceptedResponseSummary, - CreateSessionRejectedResponseSummary, CreateSessionResponseSummary, - CreateSessionResponseSummaryError, EchoMessageEvidence, EchoMessageEvidenceError, - Gtpv2cClientResponseEvidence, Gtpv2cClientTransaction, Gtpv2cClientTransactionDecision, - Gtpv2cClientTransactionKey, Gtpv2cClientTransactionMismatch, Gtpv2cClientTransactionPlan, - Gtpv2cClientTransactionPlanError, Gtpv2cClientTransactionPolicy, + decode_create_session_response_summary, + decode_create_session_response_summary_with_receive_policy, decode_echo_message_evidence, + is_s2b_message_type, s2b_create_session_accepted_response, + s2b_create_session_rejected_response, s2b_create_session_request, s2b_delete_session_request, + s2b_delete_session_response, s2b_echo_request, s2b_echo_response, s2b_modify_bearer_request, + s2b_modify_bearer_response, s2b_ue_ipsec_tunnel_update_request, + CreateSessionAcceptedResponseSummary, CreateSessionRejectedResponseSummary, + CreateSessionResponseSummary, CreateSessionResponseSummaryError, EchoMessageEvidence, + EchoMessageEvidenceError, Gtpv2cClientResponseEvidence, Gtpv2cClientTransaction, + Gtpv2cClientTransactionDecision, Gtpv2cClientTransactionKey, Gtpv2cClientTransactionMismatch, + Gtpv2cClientTransactionPlan, Gtpv2cClientTransactionPlanError, Gtpv2cClientTransactionPolicy, Gtpv2cClientTransactionProjection, Gtpv2cClientTransactionSnapshot, Gtpv2cClientTransactionState, Gtpv2cEchoPeer, Gtpv2cEchoPeerBlocker, Gtpv2cEchoPeerError, Gtpv2cEchoPeerEvent, Gtpv2cEchoPeerPolicy, Gtpv2cEchoPeerProjection, Gtpv2cEchoPeerReadiness, Gtpv2cEchoPeerSnapshot, Gtpv2cEchoPeerState, Gtpv2cEchoPeerTransition, Gtpv2cPeerToken, MessageDirection, Procedure, S2bAaaProvidedMsisdn, S2bCreateSessionAcceptedResponse, S2bCreateSessionContext, S2bCreateSessionContextSummary, S2bCreateSessionIdentity, - S2bCreateSessionRejectedResponse, S2bCreateSessionRequest, S2bDecodedMessage, - S2bDeleteSessionContext, S2bDeleteSessionContextSummary, S2bDeleteSessionRequest, - S2bDeleteSessionResponse, S2bMessage, S2bModifyBearerRequest, S2bModifyBearerResponse, - S2bProcedureMessage, S2bProfileBuildError, S2bProfileBuildResult, S2bReceiveDiagnostics, - S2bSessionContextProjectionError, S2bUeEndpoint, S2bUeIpsecTunnelUpdateEndpoint, - S2bUeIpsecTunnelUpdateProjectionError, S2bUeIpsecTunnelUpdateRequest, - S2bUeIpsecTunnelUpdateRequestSummary, S2bUeIpsecTunnelUpdateResponseSummary, S2bUeNatTraversal, - CREATE_BEARER_REQUEST, CREATE_BEARER_RESPONSE, CREATE_SESSION_REQUEST, CREATE_SESSION_RESPONSE, - DELETE_BEARER_REQUEST, DELETE_BEARER_RESPONSE, DELETE_SESSION_REQUEST, DELETE_SESSION_RESPONSE, - ECHO_REQUEST, ECHO_RESPONSE, INTERFACE_TYPE_S2B_EPDG_GTP_C, INTERFACE_TYPE_S2B_PGW_GTP_C, + S2bCreateSessionRejectedResponse, S2bCreateSessionRequest, + S2bCreateSessionResponseReceivePolicy, S2bDecodedMessage, S2bDeleteSessionContext, + S2bDeleteSessionContextSummary, S2bDeleteSessionRequest, S2bDeleteSessionResponse, S2bMessage, + S2bModifyBearerRequest, S2bModifyBearerResponse, S2bProcedureMessage, S2bProfileBuildError, + S2bProfileBuildResult, S2bReceiveDiagnostics, S2bSessionContextProjectionError, S2bUeEndpoint, + S2bUeIpsecTunnelUpdateEndpoint, S2bUeIpsecTunnelUpdateProjectionError, + S2bUeIpsecTunnelUpdateRequest, S2bUeIpsecTunnelUpdateRequestSummary, + S2bUeIpsecTunnelUpdateResponseSummary, S2bUeNatTraversal, CREATE_BEARER_REQUEST, + CREATE_BEARER_RESPONSE, CREATE_SESSION_REQUEST, CREATE_SESSION_RESPONSE, DELETE_BEARER_REQUEST, + DELETE_BEARER_RESPONSE, DELETE_SESSION_REQUEST, DELETE_SESSION_RESPONSE, ECHO_REQUEST, + ECHO_RESPONSE, INTERFACE_TYPE_S2B_EPDG_GTP_C, INTERFACE_TYPE_S2B_PGW_GTP_C, INTERFACE_TYPE_S2B_U_EPDG_GTP_U, INTERFACE_TYPE_S2B_U_PGW_GTP_U, MODIFY_BEARER_REQUEST, MODIFY_BEARER_RESPONSE, UPDATE_BEARER_REQUEST, UPDATE_BEARER_RESPONSE, }; diff --git a/crates/opc-proto-gtpv2c/src/s2b.rs b/crates/opc-proto-gtpv2c/src/s2b.rs index 860965ed..561f6efc 100644 --- a/crates/opc-proto-gtpv2c/src/s2b.rs +++ b/crates/opc-proto-gtpv2c/src/s2b.rs @@ -104,6 +104,9 @@ pub const INTERFACE_TYPE_S2B_PGW_GTP_C: u8 = 32; /// Table 8.22-1. pub const INTERFACE_TYPE_S2B_U_PGW_GTP_U: u8 = 33; +const INTERFACE_TYPE_S5_S8_PGW_GTP_U: u8 = 5; +const INTERFACE_TYPE_S5_S8_PGW_GTP_C: u8 = 7; + /// Result type for S2b Production Profile v1 constructors. pub type S2bProfileBuildResult = Result; @@ -503,8 +506,14 @@ pub struct S2bCreateSessionAcceptedResponse<'a> { /// non-zero TEID, and include at least one IPv4 or IPv6 address. pub pgw_control_f_teid: FullyQualifiedTeid, /// Bearer Context IE containing the accepted bearer EBI. + /// + /// Any included PGW user-plane F-TEID must use + /// [`INTERFACE_TYPE_S2B_U_PGW_GTP_U`]. Receive-only compatibility policy + /// never broadens builder output. pub bearer_context: BearerContext<'a>, /// Additional typed IEs to append after Cause, PGW control F-TEID, and Bearer Context. + /// + /// Additional F-TEIDs cannot introduce a non-S2b interface role. pub additional_ies: Vec>, } @@ -518,6 +527,8 @@ pub struct S2bCreateSessionRejectedResponse<'a> { /// Non-accepted Cause value. pub cause: CauseValue, /// Additional typed IEs to append after Cause. + /// + /// Additional F-TEIDs cannot introduce a non-S2b interface role. pub additional_ies: Vec>, } @@ -1503,9 +1514,80 @@ fn validate_built_s2b_profile_message(message: &OwnedMessage) -> Result<(), Deco } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PgwControlReceiveRoleSet { + S2bOnly, + S2bOrS5S8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PgwUserPlaneReceiveRoleSet { + S2bOnly, + S2bOrS5S8, +} + +/// Finite receive policy for PGW endpoints in an S2b Create Session Response. +/// +/// The strict default accepts only S2b PGW control type 32 and S2b-U PGW +/// user-plane type 33. Callers may independently opt each role into its +/// standardized S5/S8 counterpart (control type 7 or user-plane type 5). +/// There is deliberately no interface-number or collection-based constructor. +/// +/// The policy is copied into the one-shot response decoder, so one decode and +/// its typed projection always use the same immutable role sets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct S2bCreateSessionResponseReceivePolicy { + pgw_control: PgwControlReceiveRoleSet, + pgw_user_plane: PgwUserPlaneReceiveRoleSet, +} + +impl S2bCreateSessionResponseReceivePolicy { + /// Strict S2b receive policy: control `{32}` and user-plane `{33}`. + pub const STRICT: Self = Self { + pgw_control: PgwControlReceiveRoleSet::S2bOnly, + pgw_user_plane: PgwUserPlaneReceiveRoleSet::S2bOnly, + }; + + /// Return a policy that also accepts S5/S8 PGW control type 7. + #[must_use] + pub const fn allow_s5_s8_pgw_control(self) -> Self { + Self { + pgw_control: PgwControlReceiveRoleSet::S2bOrS5S8, + pgw_user_plane: self.pgw_user_plane, + } + } + + /// Return a policy that also accepts S5/S8 PGW user-plane type 5. + #[must_use] + pub const fn allow_s5_s8_pgw_user_plane(self) -> Self { + Self { + pgw_control: self.pgw_control, + pgw_user_plane: PgwUserPlaneReceiveRoleSet::S2bOrS5S8, + } + } + + const fn accepts_pgw_control(self, interface_type: u8) -> bool { + interface_type == INTERFACE_TYPE_S2B_PGW_GTP_C + || (matches!(self.pgw_control, PgwControlReceiveRoleSet::S2bOrS5S8) + && interface_type == INTERFACE_TYPE_S5_S8_PGW_GTP_C) + } + + const fn accepts_pgw_user_plane(self, interface_type: u8) -> bool { + interface_type == INTERFACE_TYPE_S2B_U_PGW_GTP_U + || (matches!(self.pgw_user_plane, PgwUserPlaneReceiveRoleSet::S2bOrS5S8) + && interface_type == INTERFACE_TYPE_S5_S8_PGW_GTP_U) + } +} + +impl Default for S2bCreateSessionResponseReceivePolicy { + fn default() -> Self { + Self::STRICT + } +} + /// Accepted Create Session Response projection. /// -/// This projection is intentionally strict: it is returned for TS 29.274 +/// This projection is intentionally complete: it is returned for TS 29.274 /// accepted causes 16 (`RequestAccepted`) and 17 /// (`RequestAcceptedPartially`) and includes the accepted-bearer fields that /// products need to derive an established bearer context. Consumers must @@ -1518,11 +1600,11 @@ pub struct CreateSessionAcceptedResponseSummary { pub sequence_number: u32, /// Cause value from the Cause IE. pub cause: CauseValue, - /// Top-level PGW S2b control-plane F-TEID at instance 1. + /// Top-level PGW control-plane F-TEID selected at instance 1. pub pgw_control_f_teid: FullyQualifiedTeid, /// Linked bearer EBI from the first Bearer Context IE. pub bearer_ebi: EpsBearerId, - /// PGW S2b-U user-plane F-TEID from the accepted Bearer Context. + /// PGW user-plane F-TEID selected from the accepted Bearer Context. pub bearer_user_plane_f_teid: FullyQualifiedTeid, /// PGW-allocated PDN Address Allocation from top-level PAA IE instance 0. pub paa: Option, @@ -1600,7 +1682,7 @@ impl fmt::Debug for CreateSessionAcceptedResponseSummary { /// /// Rejected responses do not require accepted-bearer-only fields such as /// PGW control F-TEID or Bearer Context EBI. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct CreateSessionRejectedResponseSummary { /// TEID carried in the Create Session Response common header. pub response_teid: u32, @@ -1610,6 +1692,17 @@ pub struct CreateSessionRejectedResponseSummary { pub cause: CauseValue, } +impl fmt::Debug for CreateSessionRejectedResponseSummary { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CreateSessionRejectedResponseSummary") + .field("response_teid_present", &true) + .field("sequence_number", &self.sequence_number) + .field("cause", &self.cause) + .finish() + } +} + /// Create Session Response projection split by bearer-establishment outcome. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CreateSessionResponseSummary { @@ -1630,9 +1723,9 @@ pub enum CreateSessionResponseSummaryError { MissingCause, /// Create Session Response did not carry a response-header TEID. MissingResponseTeid, - /// Accepted response did not include the PGW S2b control F-TEID at instance 1. + /// Accepted response did not include the PGW control F-TEID at instance 1. AcceptedResponseMissingPgwControlFTeid, - /// Accepted response PGW control F-TEID did not use S2b PGW GTP-C type 32. + /// Accepted response PGW control F-TEID was outside the active role set. AcceptedResponsePgwControlFTeidInterfaceMismatch, /// Accepted response PGW control F-TEID carried the reserved zero TEID. AcceptedResponseZeroPgwControlFTeid, @@ -1646,9 +1739,9 @@ pub enum CreateSessionResponseSummaryError { AcceptedResponseMalformedPaa, /// Accepted Create Session Response Bearer Context contained no F-TEID IE. AcceptedResponseMissingBearerFTeid, - /// Accepted Create Session Response Bearer Context F-TEIDs were not S2b-U PGW. + /// Accepted response Bearer Context F-TEIDs were outside the active user-plane role set. AcceptedResponseBearerFTeidInterfaceMismatch, - /// Accepted Create Session Response S2b-U F-TEID carried no endpoint address. + /// Accepted response PGW user-plane F-TEID carried no endpoint address. AcceptedResponseMalformedBearerFTeid, } @@ -3232,7 +3325,7 @@ impl<'a> S2bProcedureMessage<'a> { pub fn create_session_response_summary( &self, ) -> Result { - project_create_session_response(self) + project_create_session_response(self, S2bCreateSessionResponseReceivePolicy::STRICT) } /// Project an S2b UE-initiated IPsec tunnel update request. @@ -3451,9 +3544,20 @@ impl<'a> S2bDecodedMessage<'a> { #[derive(Clone, Copy)] enum S2bDecodePurpose { Receive, + CreateSessionResponseProjection, CanonicalBuilder, } +impl S2bDecodePurpose { + const fn is_receive(self) -> bool { + matches!(self, Self::Receive | Self::CreateSessionResponseProjection) + } + + const fn validates_required_ies(self) -> bool { + !matches!(self, Self::CreateSessionResponseProjection) + } +} + impl fmt::Debug for S2bMessage<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -3577,8 +3681,7 @@ impl<'a> S2bMessage<'a> { }; let mut typed_ctx = ctx; - if is_procedure_aware(ctx.validation_level) && matches!(purpose, S2bDecodePurpose::Receive) - { + if is_procedure_aware(ctx.validation_level) && purpose.is_receive() { // TS 29.274 clause 7.7.10 is a receiver rule. Canonical sender // validation deliberately retains Reject below. typed_ctx.duplicate_ie_policy = DuplicateIePolicy::First; @@ -3602,7 +3705,9 @@ impl<'a> S2bMessage<'a> { // has no receive grammar and passes no resolver; its `Reject` policy // gates the discard off regardless. let malformed_optional = match purpose { - S2bDecodePurpose::Receive => MalformedOptionalIePolicy::Discard, + S2bDecodePurpose::Receive | S2bDecodePurpose::CreateSessionResponseProjection => { + MalformedOptionalIePolicy::Discard + } S2bDecodePurpose::CanonicalBuilder => MalformedOptionalIePolicy::Reject, }; let slot_is_optional = |ie_type, instance, depth, parent_ie| { @@ -3614,11 +3719,10 @@ impl<'a> S2bMessage<'a> { instance, ) }; - let presence_resolver = matches!(purpose, S2bDecodePurpose::Receive) + let presence_resolver = purpose + .is_receive() .then_some(&slot_is_optional as &dyn Fn(u8, u8, usize, Option<(u8, u8)>) -> bool); - let decoded_ies = if is_procedure_aware(ctx.validation_level) - && matches!(purpose, S2bDecodePurpose::Receive) - { + let decoded_ies = if is_procedure_aware(ctx.validation_level) && purpose.is_receive() { let filter = |ie_type, instance, depth, parent_ie| { !matches!( receive_ie_disposition( @@ -3673,7 +3777,9 @@ impl<'a> S2bMessage<'a> { raw_ies: message.raw_ies, tail: message.tail, }; - validate_required_ies(&view, ctx)?; + if purpose.validates_required_ies() { + validate_required_ies(&view, ctx, purpose)?; + } let message = match (procedure, direction) { (Procedure::Echo, MessageDirection::Request) => Self::EchoRequest(view), @@ -3762,7 +3868,7 @@ impl<'a> S2bMessage<'a> { let Self::CreateSessionResponse(view) = self else { return Err(CreateSessionResponseSummaryError::NotCreateSessionResponse); }; - view.create_session_response_summary() + project_create_session_response(view, S2bCreateSessionResponseReceivePolicy::STRICT) } /// Return this message's typed GTPv2-C message type, including unknown raw fallbacks. @@ -3805,13 +3911,46 @@ pub fn decode_create_session_response_summary( input: &[u8], ctx: DecodeContext, ) -> Result { - let projection_ctx = create_session_response_projection_context(ctx); - let (tail, message) = S2bMessage::decode(input, projection_ctx) + decode_create_session_response_summary_with_receive_policy( + input, + ctx, + S2bCreateSessionResponseReceivePolicy::STRICT, + ) +} + +/// Decode and project one S2b Create Session Response with a finite receive policy. +/// +/// `policy` applies only to the two PGW endpoint roles in an accepted Create +/// Session Response. It does not alter builders, other procedures, IE grammar, +/// or any other F-TEID role. Procedure-aware receive filtering and first-wins +/// singleton handling occur before the response is resolved exactly once with +/// the copied policy. +/// +/// # Errors +/// +/// Returns [`CreateSessionResponseSummaryError`] when bytes are malformed, +/// contain trailing data after the message, decode to another message type, or +/// fail policy-bound Create Session Response projection. +pub fn decode_create_session_response_summary_with_receive_policy( + input: &[u8], + ctx: DecodeContext, + policy: S2bCreateSessionResponseReceivePolicy, +) -> Result { + let (tail, message) = Message::decode_annotated(input, ctx) .map_err(|_| CreateSessionResponseSummaryError::MalformedResponse)?; if !tail.is_empty() { return Err(CreateSessionResponseSummaryError::MalformedResponse); } - message.create_session_response_summary() + let decoded = S2bMessage::from_message_with_purpose( + message, + ctx, + S2bDecodePurpose::CreateSessionResponseProjection, + ) + .map_err(|_| CreateSessionResponseSummaryError::MalformedResponse)?; + let S2bMessage::CreateSessionResponse(view) = decoded.into_message() else { + return Err(CreateSessionResponseSummaryError::NotCreateSessionResponse); + }; + project_create_session_response(&view, policy) } impl<'a> BorrowDecode<'a> for S2bMessage<'a> { @@ -3867,18 +4006,6 @@ impl Encode for S2bMessage<'_> { } } -fn create_session_response_projection_context(mut ctx: DecodeContext) -> DecodeContext { - if ctx.validation_level == ValidationLevel::ProcedureAware { - // Keep the public projection helper's stable, field-specific errors by - // deferring procedure validation, but retain ProcedureAware receive - // semantics: an invalid first singleton must never be repaired by a - // later duplicate under a caller/default Last policy. - ctx.duplicate_ie_policy = DuplicateIePolicy::First; - ctx.validation_level = ValidationLevel::Strict; - } - ctx -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ReceiveIeDisposition { AllowedKnown, @@ -5020,19 +5147,16 @@ fn find_recovery_restart_counter(ies: &[TypedIe<'_>]) -> Option { fn find_pgw_control_f_teid( ies: &[TypedIe<'_>], + policy: S2bCreateSessionResponseReceivePolicy, ) -> Result { - let f_teid = ies + let ie = ies .iter() - .find_map(|ie| match &ie.value { - TypedIeValue::FullyQualifiedTeid(f_teid) - if ie.ie_type() == IE_TYPE_F_TEID && ie.instance == 1 => - { - Some(f_teid) - } - _ => None, - }) + .find(|ie| ie.ie_type() == IE_TYPE_F_TEID && ie.instance == 1) .ok_or(CreateSessionResponseSummaryError::AcceptedResponseMissingPgwControlFTeid)?; - if f_teid.interface_type != INTERFACE_TYPE_S2B_PGW_GTP_C { + let TypedIeValue::FullyQualifiedTeid(f_teid) = &ie.value else { + return Err(CreateSessionResponseSummaryError::AcceptedResponseMalformedPgwControlFTeid); + }; + if !policy.accepts_pgw_control(f_teid.interface_type) { return Err( CreateSessionResponseSummaryError::AcceptedResponsePgwControlFTeidInterfaceMismatch, ); @@ -5049,27 +5173,25 @@ fn find_pgw_control_f_teid( fn find_bearer_context_ebi( ies: &[TypedIe<'_>], ) -> Result { - let Some(context) = ies.iter().find_map(|ie| match &ie.value { - TypedIeValue::BearerContext(context) - if ie.ie_type() == IE_TYPE_BEARER_CONTEXT && ie.instance == 0 => - { - Some(context) - } - _ => None, - }) else { + let Some(ie) = ies + .iter() + .find(|ie| ie.ie_type() == IE_TYPE_BEARER_CONTEXT && ie.instance == 0) + else { + return Err(CreateSessionResponseSummaryError::AcceptedResponseMissingBearerContext); + }; + let TypedIeValue::BearerContext(context) = &ie.value else { return Err(CreateSessionResponseSummaryError::AcceptedResponseMissingBearerContext); }; - context + let ie = context .members .iter() - .find_map(|ie| match &ie.value { - TypedIeValue::EpsBearerId(ebi) if ie.ie_type() == IE_TYPE_EBI && ie.instance == 0 => { - Some(*ebi) - } - _ => None, - }) - .ok_or(CreateSessionResponseSummaryError::AcceptedResponseMissingBearerEbi) + .find(|ie| ie.ie_type() == IE_TYPE_EBI && ie.instance == 0) + .ok_or(CreateSessionResponseSummaryError::AcceptedResponseMissingBearerEbi)?; + match &ie.value { + TypedIeValue::EpsBearerId(ebi) => Ok(*ebi), + _ => Err(CreateSessionResponseSummaryError::AcceptedResponseMissingBearerEbi), + } } fn find_response_paa( @@ -5246,30 +5368,36 @@ fn project_delete_session_context( }) } -fn find_bearer_context_s2b_u_f_teid( +fn find_bearer_context_pgw_user_plane_f_teid( ies: &[TypedIe<'_>], + policy: S2bCreateSessionResponseReceivePolicy, ) -> Result { - let Some(context) = ies.iter().find_map(|ie| match &ie.value { - TypedIeValue::BearerContext(context) - if ie.ie_type() == IE_TYPE_BEARER_CONTEXT && ie.instance == 0 => - { - Some(context) - } - _ => None, - }) else { + let Some(ie) = ies + .iter() + .find(|ie| ie.ie_type() == IE_TYPE_BEARER_CONTEXT && ie.instance == 0) + else { + return Err(CreateSessionResponseSummaryError::AcceptedResponseMissingBearerContext); + }; + let TypedIeValue::BearerContext(context) = &ie.value else { return Err(CreateSessionResponseSummaryError::AcceptedResponseMissingBearerContext); }; let mut saw_f_teid = false; + let mut seen_instances = [false; 16]; for member in &context.members { - let TypedIeValue::FullyQualifiedTeid(f_teid) = &member.value else { - continue; - }; if member.ie_type() != IE_TYPE_F_TEID { continue; } + let wire_instance = usize::from(member.instance & 0x0f); + if seen_instances[wire_instance] { + continue; + } + seen_instances[wire_instance] = true; saw_f_teid = true; - if f_teid.interface_type == INTERFACE_TYPE_S2B_U_PGW_GTP_U { + let TypedIeValue::FullyQualifiedTeid(f_teid) = &member.value else { + continue; + }; + if policy.accepts_pgw_user_plane(f_teid.interface_type) { if f_teid.ipv4.is_none() && f_teid.ipv6.is_none() { return Err( CreateSessionResponseSummaryError::AcceptedResponseMalformedBearerFTeid, @@ -5286,6 +5414,34 @@ fn find_bearer_context_s2b_u_f_teid( } } +struct AcceptedCreateSessionResponseFields { + pgw_control_f_teid: FullyQualifiedTeid, + bearer_ebi: EpsBearerId, + bearer_user_plane_f_teid: Option, +} + +fn resolve_accepted_create_session_response_fields( + ies: &[TypedIe<'_>], + policy: S2bCreateSessionResponseReceivePolicy, +) -> Result { + let pgw_control_f_teid = find_pgw_control_f_teid(ies, policy)?; + let bearer_ebi = find_bearer_context_ebi(ies)?; + // Generic ProcedureAware decoding historically accepts an EBI-only + // Bearer Context, while typed response projection requires the endpoint. + // Absence is the sole requiredness distinction: every present candidate + // is selected and policy-checked here for both paths. + let bearer_user_plane_f_teid = match find_bearer_context_pgw_user_plane_f_teid(ies, policy) { + Ok(f_teid) => Some(f_teid), + Err(CreateSessionResponseSummaryError::AcceptedResponseMissingBearerFTeid) => None, + Err(error) => return Err(error), + }; + Ok(AcceptedCreateSessionResponseFields { + pgw_control_f_teid, + bearer_ebi, + bearer_user_plane_f_teid, + }) +} + fn is_accepted_create_session_cause(cause: CauseValue) -> bool { matches!( cause, @@ -5295,6 +5451,7 @@ fn is_accepted_create_session_cause(cause: CauseValue) -> bool { fn project_create_session_response( view: &S2bProcedureMessage<'_>, + policy: S2bCreateSessionResponseReceivePolicy, ) -> Result { if view.procedure != Procedure::CreateSession || view.direction != MessageDirection::Response { return Err(CreateSessionResponseSummaryError::NotCreateSessionResponse); @@ -5309,9 +5466,10 @@ fn project_create_session_response( find_cause_value(&view.ies).ok_or(CreateSessionResponseSummaryError::MissingCause)?; if is_accepted_create_session_cause(cause) { - let pgw_control_f_teid = find_pgw_control_f_teid(&view.ies)?; - let bearer_ebi = find_bearer_context_ebi(&view.ies)?; - let bearer_user_plane_f_teid = find_bearer_context_s2b_u_f_teid(&view.ies)?; + let fields = resolve_accepted_create_session_response_fields(&view.ies, policy)?; + let bearer_user_plane_f_teid = fields + .bearer_user_plane_f_teid + .ok_or(CreateSessionResponseSummaryError::AcceptedResponseMissingBearerFTeid)?; let paa = find_response_paa(&view.ies)?; let pco = find_response_pco(&view.ies); @@ -5320,8 +5478,8 @@ fn project_create_session_response( response_teid, sequence_number, cause, - pgw_control_f_teid, - bearer_ebi, + pgw_control_f_teid: fields.pgw_control_f_teid, + bearer_ebi: fields.bearer_ebi, bearer_user_plane_f_teid, paa, pco, @@ -5427,9 +5585,83 @@ fn require_ie_instance( } } +fn create_session_response_validation_error( + error: CreateSessionResponseSummaryError, +) -> DecodeError { + let reason = match error { + CreateSessionResponseSummaryError::AcceptedResponseMissingPgwControlFTeid => { + "Create Session Response requires PGW S2b control F-TEID IE at instance 1" + } + CreateSessionResponseSummaryError::AcceptedResponsePgwControlFTeidInterfaceMismatch => { + "Create Session Response PGW control F-TEID must use S2b PGW GTP-C interface type" + } + CreateSessionResponseSummaryError::AcceptedResponseZeroPgwControlFTeid => { + "Create Session Response PGW control F-TEID must use a non-zero TEID" + } + CreateSessionResponseSummaryError::AcceptedResponseMalformedPgwControlFTeid => { + "Create Session Response PGW control F-TEID requires an endpoint address" + } + CreateSessionResponseSummaryError::AcceptedResponseMissingBearerContext => { + "Create Session Response requires Bearer Context IE at instance 0" + } + CreateSessionResponseSummaryError::AcceptedResponseMissingBearerEbi => { + "Create Session Response Bearer Context requires EBI IE" + } + CreateSessionResponseSummaryError::AcceptedResponseBearerFTeidInterfaceMismatch => { + "Create Session Response Bearer Context F-TEID must use S2b-U PGW GTP-U interface type" + } + CreateSessionResponseSummaryError::AcceptedResponseMalformedBearerFTeid => { + "Create Session Response S2b-U F-TEID requires an endpoint address" + } + _ => "Create Session Response accepted-bearer fields are invalid", + }; + missing_ie_error(reason) +} + +fn validate_builder_create_session_response_f_teids( + ies: &[TypedIe<'_>], +) -> Result<(), DecodeError> { + for ie in ies { + if let TypedIeValue::FullyQualifiedTeid(f_teid) = &ie.value { + if ie.ie_type() == IE_TYPE_F_TEID + && (ie.instance != 1 || f_teid.interface_type != INTERFACE_TYPE_S2B_PGW_GTP_C) + { + return Err(missing_ie_error( + "Create Session Response builder top-level F-TEID must be PGW S2b control type at instance 1", + )); + } + } + if let TypedIeValue::BearerContext(context) = &ie.value { + validate_builder_bearer_context_f_teids(context)?; + } + } + Ok(()) +} + +fn validate_builder_bearer_context_f_teids(context: &BearerContext<'_>) -> Result<(), DecodeError> { + for member in &context.members { + match &member.value { + TypedIeValue::FullyQualifiedTeid(f_teid) + if member.ie_type() == IE_TYPE_F_TEID + && f_teid.interface_type != INTERFACE_TYPE_S2B_U_PGW_GTP_U => + { + return Err(missing_ie_error( + "Create Session Response builder Bearer Context F-TEIDs must use S2b-U PGW GTP-U interface type", + )); + } + TypedIeValue::BearerContext(nested) => { + validate_builder_bearer_context_f_teids(nested)?; + } + _ => {} + } + } + Ok(()) +} + fn validate_required_ies( view: &S2bProcedureMessage<'_>, ctx: DecodeContext, + purpose: S2bDecodePurpose, ) -> Result<(), DecodeError> { if !is_procedure_aware(ctx.validation_level) { return Ok(()); @@ -5527,40 +5759,18 @@ fn validate_required_ies( (Procedure::CreateSession, MessageDirection::Response) => { let cause = find_cause_value(&view.ies) .ok_or_else(|| missing_ie_error("Create Session Response requires Cause IE"))?; + if matches!(purpose, S2bDecodePurpose::CanonicalBuilder) { + validate_builder_create_session_response_f_teids(&view.ies)?; + } if !is_accepted_create_session_cause(cause) { return Ok(()); } - find_pgw_control_f_teid(&view.ies).map_err(|error| { - let reason = match error { - CreateSessionResponseSummaryError::AcceptedResponseMissingPgwControlFTeid => { - "Create Session Response requires PGW S2b control F-TEID IE at instance 1" - } - CreateSessionResponseSummaryError::AcceptedResponsePgwControlFTeidInterfaceMismatch => { - "Create Session Response PGW control F-TEID must use S2b PGW GTP-C interface type" - } - CreateSessionResponseSummaryError::AcceptedResponseZeroPgwControlFTeid => { - "Create Session Response PGW control F-TEID must use a non-zero TEID" - } - CreateSessionResponseSummaryError::AcceptedResponseMalformedPgwControlFTeid => { - "Create Session Response PGW control F-TEID requires an endpoint address" - } - _ => "Create Session Response PGW control F-TEID is invalid", - }; - missing_ie_error(reason) - })?; - require_ie_instance( + let _fields = resolve_accepted_create_session_response_fields( &view.ies, - IE_TYPE_BEARER_CONTEXT, - 0, - "Create Session Response requires Bearer Context IE at instance 0", - )?; - if contains_bearer_context_with_ebi(&view.ies) { - Ok(()) - } else { - Err(missing_ie_error( - "Create Session Response Bearer Context requires EBI IE", - )) - } + S2bCreateSessionResponseReceivePolicy::STRICT, + ) + .map_err(create_session_response_validation_error)?; + Ok(()) } (Procedure::ModifyBearer, MessageDirection::Request) => { if !view.header.teid_flag || view.header.teid.is_none() { @@ -6678,19 +6888,41 @@ mod tests { "s2b_create_session_response_missing_bearer_f_teid" ); - let message = accepted_response( - vec![ - bearer_ebi(5), + let view = S2bProcedureMessage { + header: Header::with_teid(CREATE_SESSION_RESPONSE, 0x0102_0304, 0x0001_0203), + procedure: Procedure::CreateSession, + direction: MessageDirection::Response, + ies: vec![ + typed_ie(0, TypedIeValue::Cause(accepted_cause())), + typed_ie( + 1, + TypedIeValue::FullyQualifiedTeid(f_teid( + INTERFACE_TYPE_S2B_PGW_GTP_C, + 0x1111_2222, + [192, 0, 2, 1], + )), + ), typed_ie( 0, - TypedIeValue::FullyQualifiedTeid(f_teid(32, 0x1122_3344, [203, 0, 113, 1])), + TypedIeValue::BearerContext(bearer_context(vec![ + bearer_ebi(5), + typed_ie( + 0, + TypedIeValue::FullyQualifiedTeid(f_teid( + INTERFACE_TYPE_S2B_PGW_GTP_C, + 0x1122_3344, + [203, 0, 113, 1], + )), + ), + ])), ), ], - Vec::new(), - ); - let error = match decode_create_session_response_summary( - &encode_owned(&message), - DecodeContext::default(), + raw_ies: &[], + tail: &[], + }; + let error = match project_create_session_response( + &view, + S2bCreateSessionResponseReceivePolicy::STRICT, ) { Ok(summary) => panic!("unexpected summary: {summary:?}"), Err(error) => error, @@ -6737,7 +6969,10 @@ mod tests { tail: &[], }; - let error = match project_create_session_response(&view) { + let error = match project_create_session_response( + &view, + S2bCreateSessionResponseReceivePolicy::STRICT, + ) { Ok(summary) => panic!("unexpected summary: {summary:?}"), Err(error) => error, }; @@ -6752,6 +6987,65 @@ mod tests { ); } + #[test] + fn accepted_create_session_policy_resolver_never_repairs_a_retained_endpoint() { + let policy = S2bCreateSessionResponseReceivePolicy::STRICT + .allow_s5_s8_pgw_control() + .allow_s5_s8_pgw_user_plane(); + let response_view = |members| S2bProcedureMessage { + header: Header::with_teid(CREATE_SESSION_RESPONSE, 0x0102_0304, 0x0001_0203), + procedure: Procedure::CreateSession, + direction: MessageDirection::Response, + ies: vec![ + typed_ie(0, TypedIeValue::Cause(accepted_cause())), + typed_ie( + 1, + TypedIeValue::FullyQualifiedTeid(f_teid(7, 1, [192, 0, 2, 1])), + ), + typed_ie(0, TypedIeValue::BearerContext(bearer_context(members))), + ], + raw_ies: &[], + tail: &[], + }; + + let same_instance = response_view(vec![ + bearer_ebi(5), + typed_ie( + 0, + TypedIeValue::FullyQualifiedTeid(f_teid(4, 2, [198, 51, 100, 1])), + ), + typed_ie( + 0, + TypedIeValue::FullyQualifiedTeid(f_teid(5, 3, [198, 51, 100, 2])), + ), + ]); + assert_eq!( + project_create_session_response(&same_instance, policy), + Err(CreateSessionResponseSummaryError::AcceptedResponseBearerFTeidInterfaceMismatch) + ); + + let malformed_first_role = response_view(vec![ + bearer_ebi(5), + typed_ie( + 0, + TypedIeValue::FullyQualifiedTeid(FullyQualifiedTeid { + interface_type: INTERFACE_TYPE_S5_S8_PGW_GTP_U, + teid: 4, + ipv4: None, + ipv6: None, + }), + ), + typed_ie( + 1, + TypedIeValue::FullyQualifiedTeid(f_teid(5, 5, [198, 51, 100, 3])), + ), + ]); + assert_eq!( + project_create_session_response(&malformed_first_role, policy), + Err(CreateSessionResponseSummaryError::AcceptedResponseMalformedBearerFTeid) + ); + } + #[test] fn accepted_create_session_summary_rejects_malformed_typed_pgw_control_f_teid() { let view = S2bProcedureMessage { @@ -6788,7 +7082,10 @@ mod tests { tail: &[], }; - let error = match project_create_session_response(&view) { + let error = match project_create_session_response( + &view, + S2bCreateSessionResponseReceivePolicy::STRICT, + ) { Ok(summary) => panic!("unexpected summary: {summary:?}"), Err(error) => error, }; diff --git a/crates/opc-proto-gtpv2c/tests/s2b_profile_builders.rs b/crates/opc-proto-gtpv2c/tests/s2b_profile_builders.rs index 69fc4652..06331d36 100644 --- a/crates/opc-proto-gtpv2c/tests/s2b_profile_builders.rs +++ b/crates/opc-proto-gtpv2c/tests/s2b_profile_builders.rs @@ -290,6 +290,163 @@ fn create_session_accepted_response_builder_rejects_invalid_control_endpoints() } } +#[test] +fn create_session_accepted_response_builder_never_emits_receive_policy_roles() { + let mut bearer_context = accepted_bearer_context(6, 0x1122_3344); + let user_plane = bearer_context + .members + .iter_mut() + .find(|ie| ie.ie_type() == IE_TYPE_F_TEID) + .unwrap_or_else(|| panic!("accepted test Bearer Context needs an F-TEID")); + let TypedIeValue::FullyQualifiedTeid(user_plane) = &mut user_plane.value else { + panic!("accepted test Bearer Context F-TEID must be typed"); + }; + user_plane.interface_type = 5; + + let result = s2b_create_session_accepted_response(S2bCreateSessionAcceptedResponse { + sequence_number: 0x010204, + response_teid: 0x5566_7788, + pgw_control_f_teid: pgw_control_f_teid(0x2030_4050), + bearer_context, + additional_ies: Vec::new(), + }); + assert!(result.is_err(), "builder accepted an S5/S8 user-plane role"); + + let mut bearer_context = accepted_bearer_context(6, 0x1122_3344); + bearer_context.members.push(TypedIe { + instance: 1, + value: TypedIeValue::FullyQualifiedTeid(FullyQualifiedTeid { + interface_type: 5, + teid: 0x3040_5060, + ipv4: Some([198, 51, 100, 10]), + ipv6: None, + }), + }); + let result = s2b_create_session_accepted_response(S2bCreateSessionAcceptedResponse { + sequence_number: 0x010204, + response_teid: 0x5566_7788, + pgw_control_f_teid: pgw_control_f_teid(0x2030_4050), + bearer_context, + additional_ies: Vec::new(), + }); + assert!( + result.is_err(), + "builder emitted an extra S5/S8 role beside the strict S2b role" + ); + + let mut bearer_context = accepted_bearer_context(6, 0x1122_3344); + bearer_context.members.push(TypedIe { + instance: 1, + value: TypedIeValue::BearerContext(BearerContext { + members: vec![TypedIe { + instance: 0, + value: TypedIeValue::FullyQualifiedTeid(FullyQualifiedTeid { + interface_type: 5, + teid: 0x3545_5565, + ipv4: Some([198, 51, 100, 11]), + ipv6: None, + }), + }], + }), + }); + let result = s2b_create_session_accepted_response(S2bCreateSessionAcceptedResponse { + sequence_number: 0x010204, + response_teid: 0x5566_7788, + pgw_control_f_teid: pgw_control_f_teid(0x2030_4050), + bearer_context, + additional_ies: Vec::new(), + }); + assert!( + result.is_err(), + "builder emitted a receive-only role from a nested Bearer Context" + ); + + let result = s2b_create_session_accepted_response(S2bCreateSessionAcceptedResponse { + sequence_number: 0x010204, + response_teid: 0x5566_7788, + pgw_control_f_teid: pgw_control_f_teid(0x2030_4050), + bearer_context: accepted_bearer_context(6, 0x1122_3344), + additional_ies: vec![TypedIe { + instance: 0, + value: TypedIeValue::FullyQualifiedTeid(FullyQualifiedTeid { + interface_type: 7, + teid: 0x4050_6070, + ipv4: Some([192, 0, 2, 3]), + ipv6: None, + }), + }], + }); + assert!( + result.is_err(), + "builder emitted a receive-only role through additional IEs" + ); + + let result = s2b_create_session_accepted_response(S2bCreateSessionAcceptedResponse { + sequence_number: 0x010204, + response_teid: 0x5566_7788, + pgw_control_f_teid: FullyQualifiedTeid { + interface_type: 7, + teid: 0x2030_4050, + ipv4: Some([192, 0, 2, 2]), + ipv6: None, + }, + bearer_context: accepted_bearer_context(6, 0x1122_3344), + additional_ies: Vec::new(), + }); + assert!(result.is_err(), "builder accepted an S5/S8 control role"); +} + +#[test] +fn create_session_rejected_response_builder_never_emits_receive_policy_roles() { + let result = s2b_create_session_rejected_response(S2bCreateSessionRejectedResponse { + sequence_number: 0x010204, + response_teid: 0x5566_7788, + cause: CauseValue::MandatoryIeMissing, + additional_ies: vec![TypedIe { + instance: 1, + value: TypedIeValue::FullyQualifiedTeid(FullyQualifiedTeid { + interface_type: 7, + teid: 0x4050_6070, + ipv4: Some([192, 0, 2, 3]), + ipv6: None, + }), + }], + }); + assert!( + result.is_err(), + "rejected builder emitted an S5/S8 control role" + ); + + let result = s2b_create_session_rejected_response(S2bCreateSessionRejectedResponse { + sequence_number: 0x010204, + response_teid: 0x5566_7788, + cause: CauseValue::MandatoryIeMissing, + additional_ies: vec![TypedIe { + instance: 0, + value: TypedIeValue::BearerContext(BearerContext { + members: vec![TypedIe { + instance: 1, + value: TypedIeValue::BearerContext(BearerContext { + members: vec![TypedIe { + instance: 0, + value: TypedIeValue::FullyQualifiedTeid(FullyQualifiedTeid { + interface_type: 5, + teid: 0x5060_7080, + ipv4: Some([198, 51, 100, 12]), + ipv6: None, + }), + }], + }), + }], + }), + }], + }); + assert!( + result.is_err(), + "rejected builder emitted an S5/S8 user-plane role from a nested Bearer Context" + ); +} + #[test] fn create_session_request_builder_rejects_duplicate_profile_singletons() { let mut request = create_session_request_input(); diff --git a/crates/opc-proto-gtpv2c/tests/s2b_typed.rs b/crates/opc-proto-gtpv2c/tests/s2b_typed.rs index 52f1f90b..eae02276 100644 --- a/crates/opc-proto-gtpv2c/tests/s2b_typed.rs +++ b/crates/opc-proto-gtpv2c/tests/s2b_typed.rs @@ -1,9 +1,10 @@ use bytes::BytesMut; use opc_proto_gtpv2c::{ - decode_typed_ie_sequence, s2b, CauseValue, FullyQualifiedTeid, Message, MessageType, - PdnTypeValue, S2bMessage, TbcdDigits, TypedIe, TypedIeValue, IE_TYPE_APCO, - IE_TYPE_BEARER_CONTEXT, IE_TYPE_BEARER_QOS, IE_TYPE_CAUSE, IE_TYPE_EBI, IE_TYPE_F_TEID, - IE_TYPE_IMSI, IE_TYPE_INDICATION, IE_TYPE_IP_ADDRESS, IE_TYPE_MEI, IE_TYPE_PAA, + decode_create_session_response_summary_with_receive_policy, decode_typed_ie_sequence, s2b, + CauseValue, FullyQualifiedTeid, Message, MessageType, PdnTypeValue, + S2bCreateSessionResponseReceivePolicy, S2bMessage, TbcdDigits, TypedIe, TypedIeValue, + IE_TYPE_APCO, IE_TYPE_BEARER_CONTEXT, IE_TYPE_BEARER_QOS, IE_TYPE_CAUSE, IE_TYPE_EBI, + IE_TYPE_F_TEID, IE_TYPE_IMSI, IE_TYPE_INDICATION, IE_TYPE_IP_ADDRESS, IE_TYPE_MEI, IE_TYPE_PAA, IE_TYPE_PDN_TYPE, IE_TYPE_RECOVERY, INTERFACE_TYPE_S2B_EPDG_GTP_C, INTERFACE_TYPE_S2B_PGW_GTP_C, INTERFACE_TYPE_S2B_U_EPDG_GTP_U, INTERFACE_TYPE_S2B_U_PGW_GTP_U, }; @@ -998,6 +999,53 @@ fn raw_f_teid_ie( ie } +fn bearer_context_with_f_teids(f_teids: &[Vec]) -> Vec { + let value_len = EBI_IE.len() + + f_teids + .iter() + .map(Vec::len) + .fold(0usize, usize::saturating_add); + let value_len = match u16::try_from(value_len) { + Ok(value_len) => value_len, + Err(error) => panic!("test Bearer Context value is too long: {error:?}"), + }; + let mut bearer_context = Vec::new(); + bearer_context.push(IE_TYPE_BEARER_CONTEXT); + bearer_context.extend_from_slice(&value_len.to_be_bytes()); + bearer_context.push(0); + bearer_context.extend_from_slice(EBI_IE); + for f_teid in f_teids { + bearer_context.extend_from_slice(f_teid); + } + bearer_context +} + +fn create_session_response_with_endpoint_roles( + control_interface_type: u8, + user_plane_interface_type: u8, +) -> Vec { + let control = raw_f_teid_ie( + 1, + control_interface_type, + 0x1020_3040, + Some([192, 0, 2, 44]), + None, + ); + let user_plane = raw_f_teid_ie( + 0, + user_plane_interface_type, + 0x5060_7080, + Some([198, 51, 100, 55]), + None, + ); + let bearer_context = bearer_context_with_f_teids(&[user_plane]); + create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[CAUSE_IE, control.as_slice(), bearer_context.as_slice()], + ) +} + #[test] fn create_session_rejected_response_summary_allows_cause_only() { let response = create_session_response_with_projection_ies( @@ -1379,6 +1427,466 @@ fn public_summary_helper_keeps_first_repeated_pgw_control_endpoint() { } } +#[test] +fn create_session_response_receive_policy_defaults_remain_strict() { + let strict = create_session_response_with_endpoint_roles( + INTERFACE_TYPE_S2B_PGW_GTP_C, + INTERFACE_TYPE_S2B_U_PGW_GTP_U, + ); + assert!(s2b::decode_create_session_response_summary(&strict, procedure_context()).is_ok()); + assert!(S2bMessage::decode(&strict, procedure_context()).is_ok()); + + let s5_s8_control = + create_session_response_with_endpoint_roles(7, INTERFACE_TYPE_S2B_U_PGW_GTP_U); + assert_eq!( + s2b::decode_create_session_response_summary(&s5_s8_control, procedure_context()), + Err( + s2b::CreateSessionResponseSummaryError::AcceptedResponsePgwControlFTeidInterfaceMismatch + ) + ); + assert!(S2bMessage::decode(&s5_s8_control, procedure_context()).is_err()); + + let s5_s8_user_plane = + create_session_response_with_endpoint_roles(INTERFACE_TYPE_S2B_PGW_GTP_C, 5); + assert_eq!( + s2b::decode_create_session_response_summary(&s5_s8_user_plane, procedure_context()), + Err(s2b::CreateSessionResponseSummaryError::AcceptedResponseBearerFTeidInterfaceMismatch) + ); + assert!(S2bMessage::decode(&s5_s8_user_plane, procedure_context()).is_err()); +} + +#[test] +fn structural_message_and_view_projection_overloads_remain_strict() { + let response = create_session_response_with_endpoint_roles(7, 5); + let structural_context = DecodeContext { + validation_level: ValidationLevel::Structural, + ..DecodeContext::default() + }; + let (tail, message) = S2bMessage::decode(&response, structural_context) + .expect("structural decode does not opt into receive roles"); + assert!(tail.is_empty()); + let expected = Err( + s2b::CreateSessionResponseSummaryError::AcceptedResponsePgwControlFTeidInterfaceMismatch, + ); + assert_eq!(message.create_session_response_summary(), expected); + let view = message + .as_view() + .unwrap_or_else(|| panic!("Create Session Response needs a typed view")); + assert_eq!(view.create_session_response_summary(), expected); +} + +#[test] +fn finite_create_session_response_receive_policy_matrix_projects_exact_roles() { + let strict = S2bCreateSessionResponseReceivePolicy::STRICT; + let control = strict.allow_s5_s8_pgw_control(); + let user_plane = strict.allow_s5_s8_pgw_user_plane(); + let both = control.allow_s5_s8_pgw_user_plane(); + let policies = [ + ("strict", strict, false, false), + ("control", control, true, false), + ("user-plane", user_plane, false, true), + ("both", both, true, true), + ]; + let control_roles = [(INTERFACE_TYPE_S2B_PGW_GTP_C, false), (7, true)]; + let user_plane_roles = [(INTERFACE_TYPE_S2B_U_PGW_GTP_U, false), (5, true)]; + + for (policy_name, policy, allows_s5_s8_control, allows_s5_s8_user_plane) in policies { + for (control_interface_type, is_s5_s8_control) in control_roles { + for (user_plane_interface_type, is_s5_s8_user_plane) in user_plane_roles { + let response = create_session_response_with_endpoint_roles( + control_interface_type, + user_plane_interface_type, + ); + let result = decode_create_session_response_summary_with_receive_policy( + &response, + procedure_context(), + policy, + ); + let label = format!( + "{policy_name}: control={control_interface_type}, user={user_plane_interface_type}" + ); + if is_s5_s8_control && !allows_s5_s8_control { + assert_eq!( + result, + Err(s2b::CreateSessionResponseSummaryError::AcceptedResponsePgwControlFTeidInterfaceMismatch), + "{label}" + ); + continue; + } + if is_s5_s8_user_plane && !allows_s5_s8_user_plane { + assert_eq!( + result, + Err(s2b::CreateSessionResponseSummaryError::AcceptedResponseBearerFTeidInterfaceMismatch), + "{label}" + ); + continue; + } + + let summary = result.unwrap_or_else(|error| panic!("{label} failed: {error:?}")); + let s2b::CreateSessionResponseSummary::Accepted(accepted) = summary else { + panic!("{label} projected as rejected"); + }; + assert_eq!( + accepted.pgw_control_f_teid.interface_type, control_interface_type, + "{label}" + ); + assert_eq!(accepted.pgw_control_f_teid.teid, 0x1020_3040, "{label}"); + assert_eq!( + accepted.pgw_control_f_teid.ipv4, + Some([192, 0, 2, 44]), + "{label}" + ); + assert_eq!( + accepted.bearer_user_plane_f_teid.interface_type, user_plane_interface_type, + "{label}" + ); + assert_eq!( + accepted.bearer_user_plane_f_teid.teid, 0x5060_7080, + "{label}" + ); + assert_eq!( + accepted.bearer_user_plane_f_teid.ipv4, + Some([198, 51, 100, 55]), + "{label}" + ); + } + } + } + + assert_eq!(strict, S2bCreateSessionResponseReceivePolicy::default()); + assert_eq!(strict, S2bCreateSessionResponseReceivePolicy::STRICT); +} + +#[test] +fn create_session_response_receive_policy_role_sets_cannot_be_swapped_or_widened() { + let policy = s2b::S2bCreateSessionResponseReceivePolicy::STRICT + .allow_s5_s8_pgw_control() + .allow_s5_s8_pgw_user_plane(); + let cases = [ + ( + "swapped finite role codes", + 5, + 7, + s2b::CreateSessionResponseSummaryError::AcceptedResponsePgwControlFTeidInterfaceMismatch, + ), + ( + "unknown control role", + 6, + INTERFACE_TYPE_S2B_U_PGW_GTP_U, + s2b::CreateSessionResponseSummaryError::AcceptedResponsePgwControlFTeidInterfaceMismatch, + ), + ( + "unknown user-plane role", + INTERFACE_TYPE_S2B_PGW_GTP_C, + 4, + s2b::CreateSessionResponseSummaryError::AcceptedResponseBearerFTeidInterfaceMismatch, + ), + ]; + + for (label, control_interface_type, user_plane_interface_type, expected) in cases { + let response = create_session_response_with_endpoint_roles( + control_interface_type, + user_plane_interface_type, + ); + let error = s2b::decode_create_session_response_summary_with_receive_policy( + &response, + procedure_context(), + policy, + ) + .unwrap_err(); + assert_eq!(error, expected, "{label}"); + assert_eq!(error.to_string(), error.as_str(), "{label}"); + } +} + +#[test] +fn create_session_response_receive_policy_keeps_first_singleton_occurrences() { + let policy = s2b::S2bCreateSessionResponseReceivePolicy::STRICT + .allow_s5_s8_pgw_control() + .allow_s5_s8_pgw_user_plane(); + let receive_context = DecodeContext { + duplicate_ie_policy: DuplicateIePolicy::Last, + validation_level: ValidationLevel::ProcedureAware, + ..DecodeContext::default() + }; + + let invalid_control = raw_f_teid_ie(1, 6, 0x1111_1111, Some([192, 0, 2, 1]), None); + let valid_control = raw_f_teid_ie(1, 7, 0x2222_2222, Some([192, 0, 2, 2]), None); + let user_plane = raw_f_teid_ie(0, 5, 0x3333_3333, Some([198, 51, 100, 3]), None); + let bearer_context = bearer_context_with_f_teids(&[user_plane]); + let response = create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[ + CAUSE_IE, + invalid_control.as_slice(), + valid_control.as_slice(), + bearer_context.as_slice(), + ], + ); + assert_eq!( + s2b::decode_create_session_response_summary_with_receive_policy( + &response, + receive_context, + policy, + ), + Err( + s2b::CreateSessionResponseSummaryError::AcceptedResponsePgwControlFTeidInterfaceMismatch + ) + ); + + let control = raw_f_teid_ie(1, 7, 0x4444_4444, Some([192, 0, 2, 4]), None); + let invalid_user_plane = raw_f_teid_ie(0, 4, 0x5555_5555, Some([198, 51, 100, 5]), None); + let valid_user_plane = raw_f_teid_ie(0, 5, 0x6666_6666, Some([198, 51, 100, 6]), None); + let bearer_context = bearer_context_with_f_teids(&[invalid_user_plane, valid_user_plane]); + let response = create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[CAUSE_IE, control.as_slice(), bearer_context.as_slice()], + ); + assert_eq!( + s2b::decode_create_session_response_summary_with_receive_policy( + &response, + receive_context, + policy, + ), + Err(s2b::CreateSessionResponseSummaryError::AcceptedResponseBearerFTeidInterfaceMismatch) + ); + + let retained_user_plane = raw_f_teid_ie(0, 5, 0x6767_6767, Some([198, 51, 100, 67]), None); + let malformed_later_user_plane = raw_f_teid_ie(0, 5, 0x6868_6868, None, None); + let bearer_context = + bearer_context_with_f_teids(&[retained_user_plane, malformed_later_user_plane]); + let response = create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[CAUSE_IE, control.as_slice(), bearer_context.as_slice()], + ); + let summary = s2b::decode_create_session_response_summary_with_receive_policy( + &response, + receive_context, + policy, + ) + .expect("a malformed later nested duplicate is ignored before value decode"); + let s2b::CreateSessionResponseSummary::Accepted(accepted) = summary else { + panic!("accepted response projected as rejected"); + }; + assert_eq!(accepted.bearer_user_plane_f_teid.teid, 0x6767_6767); + + let other_role = raw_f_teid_ie(1, 4, 0x7777_7777, Some([198, 51, 100, 7]), None); + let accepted_role = raw_f_teid_ie(2, 5, 0x8888_8888, Some([198, 51, 100, 8]), None); + let bearer_context = bearer_context_with_f_teids(&[other_role, accepted_role]); + let response = create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[CAUSE_IE, control.as_slice(), bearer_context.as_slice()], + ); + let summary = s2b::decode_create_session_response_summary_with_receive_policy( + &response, + receive_context, + policy, + ) + .expect("a distinct nested singleton does not poison the accepted role"); + let s2b::CreateSessionResponseSummary::Accepted(accepted) = summary else { + panic!("accepted response projected as rejected"); + }; + assert_eq!(accepted.bearer_user_plane_f_teid.interface_type, 5); + assert_eq!(accepted.bearer_user_plane_f_teid.teid, 0x8888_8888); + + let strict_control = raw_f_teid_ie( + 1, + INTERFACE_TYPE_S2B_PGW_GTP_C, + 0x8989_8989, + Some([192, 0, 2, 9]), + None, + ); + let invalid_first_role = raw_f_teid_ie(0, 4, 0x9090_9090, Some([198, 51, 100, 9]), None); + let first_bearer_context = bearer_context_with_f_teids(&[invalid_first_role]); + let valid_later_role = raw_f_teid_ie(0, 5, 0x9191_9191, Some([198, 51, 100, 10]), None); + let later_bearer_context = bearer_context_with_f_teids(&[valid_later_role]); + let response = create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[ + CAUSE_IE, + strict_control.as_slice(), + first_bearer_context.as_slice(), + later_bearer_context.as_slice(), + ], + ); + assert_eq!( + s2b::decode_create_session_response_summary_with_receive_policy( + &response, + receive_context, + policy, + ), + Err(s2b::CreateSessionResponseSummaryError::AcceptedResponseBearerFTeidInterfaceMismatch) + ); + assert!( + S2bMessage::decode(&response, receive_context).is_err(), + "generic ProcedureAware validation must retain the same first Bearer Context" + ); +} + +#[test] +fn create_session_response_receive_policy_preserves_endpoint_failures_and_privacy() { + let policy = s2b::S2bCreateSessionResponseReceivePolicy::STRICT + .allow_s5_s8_pgw_control() + .allow_s5_s8_pgw_user_plane(); + let valid_user_plane = raw_f_teid_ie(0, 5, 0xa1a2_a3a4, Some([198, 51, 100, 77]), None); + let bearer_context = bearer_context_with_f_teids(&[valid_user_plane]); + let missing_user_plane_context = bearer_context_with_f_teids(&[]); + let missing_address_user_plane = raw_f_teid_ie(0, 5, 0xc1c2_c3c4, None, None); + let malformed_user_plane_context = bearer_context_with_f_teids(&[missing_address_user_plane]); + let valid_control = raw_f_teid_ie(1, 7, 0xd1d2_d3d4, Some([192, 0, 2, 65]), None); + let strict_control = raw_f_teid_ie( + 1, + INTERFACE_TYPE_S2B_PGW_GTP_C, + 0xe1e2_e3e4, + Some([192, 0, 2, 64]), + None, + ); + let zero_control = raw_f_teid_ie(1, 7, 0, Some([192, 0, 2, 66]), None); + let missing_address_control = raw_f_teid_ie(1, 7, 0xb1b2_b3b4, None, None); + let missing_user_plane_response = create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[ + CAUSE_IE, + strict_control.as_slice(), + missing_user_plane_context.as_slice(), + ], + ); + assert!( + S2bMessage::decode(&missing_user_plane_response, procedure_context()).is_ok(), + "generic ProcedureAware decoding preserves EBI-only compatibility" + ); + let cases = [ + ( + "missing control", + create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[CAUSE_IE, bearer_context.as_slice()], + ), + s2b::CreateSessionResponseSummaryError::AcceptedResponseMissingPgwControlFTeid, + ), + ( + "missing user-plane endpoint", + missing_user_plane_response, + s2b::CreateSessionResponseSummaryError::AcceptedResponseMissingBearerFTeid, + ), + ( + "missing user-plane address", + create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[ + CAUSE_IE, + valid_control.as_slice(), + malformed_user_plane_context.as_slice(), + ], + ), + s2b::CreateSessionResponseSummaryError::MalformedResponse, + ), + ( + "zero control TEID", + create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[CAUSE_IE, zero_control.as_slice(), bearer_context.as_slice()], + ), + s2b::CreateSessionResponseSummaryError::AcceptedResponseZeroPgwControlFTeid, + ), + ( + "missing control address", + create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[ + CAUSE_IE, + missing_address_control.as_slice(), + bearer_context.as_slice(), + ], + ), + s2b::CreateSessionResponseSummaryError::MalformedResponse, + ), + ]; + + for (label, response, expected) in cases { + let error = s2b::decode_create_session_response_summary_with_receive_policy( + &response, + procedure_context(), + policy, + ) + .unwrap_err(); + assert_eq!(error, expected, "{label}"); + let rendered = format!("{error:?} {error}"); + for private_fragment in [ + "2711790500", + "2981278644", + "3250766788", + "3520254932", + "192, 0, 2, 65", + "192, 0, 2, 66", + "198, 51, 100, 77", + ] { + assert!(!rendered.contains(private_fragment), "{label}: {rendered}"); + } + } + + let policy_debug = format!("{policy:?}"); + assert!(!policy_debug.contains("192, 0, 2")); + assert!(!policy_debug.contains("198, 51, 100")); + assert!(!policy_debug.contains("2711790500")); + + let private_response_teid = 0xf1e2_d3c4; + let rejected_response = create_session_response_with_projection_ies( + Some(private_response_teid), + 0x0000_2000, + &[REJECTED_CAUSE_IE], + ); + let rejected = s2b::decode_create_session_response_summary_with_receive_policy( + &rejected_response, + procedure_context(), + policy, + ) + .expect("rejected response projection remains policy-independent"); + let rejected_debug = format!("{rejected:?}"); + assert!(rejected_debug.contains("response_teid_present")); + assert!( + !rejected_debug.contains(&private_response_teid.to_string()), + "rejected summary leaked its response TEID: {rejected_debug}" + ); +} + +#[test] +fn create_session_response_receive_policy_preserves_user_plane_zero_teid_behavior() { + let control = raw_f_teid_ie(1, 7, 0x1020_3040, Some([192, 0, 2, 90]), None); + let user_plane = raw_f_teid_ie(0, 5, 0, Some([198, 51, 100, 90]), None); + let bearer_context = bearer_context_with_f_teids(&[user_plane]); + let response = create_session_response_with_projection_ies( + Some(0x0102_0304), + 0x0000_2000, + &[CAUSE_IE, control.as_slice(), bearer_context.as_slice()], + ); + let policy = S2bCreateSessionResponseReceivePolicy::STRICT + .allow_s5_s8_pgw_control() + .allow_s5_s8_pgw_user_plane(); + + let summary = decode_create_session_response_summary_with_receive_policy( + &response, + procedure_context(), + policy, + ) + .expect("the existing user-plane zero-TEID behavior remains unchanged"); + let s2b::CreateSessionResponseSummary::Accepted(accepted) = summary else { + panic!("accepted response projected as rejected"); + }; + assert_eq!(accepted.bearer_user_plane_f_teid.interface_type, 5); + assert_eq!(accepted.bearer_user_plane_f_teid.teid, 0); +} + #[test] fn create_session_partial_accept_response_summary_projects_bearer_fields() { let response = create_session_response_with_projection_ies( diff --git a/docs/implementation-status.md b/docs/implementation-status.md index d4fb8582..05c92b32 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1320,6 +1320,6 @@ identity. | GAP-PROTO-004 | 005 | OpenAPI SBI codegen | partial — [`openapi-codegen-plan.md`](design/openapi-codegen-plan.md) | `opc-api-nnrf` | Partially closed (June 2026): pilot expanded to TS 29.510 NFManagement `NfProfile`/`NfService` plus `SubscriptionData`/`NotificationData` and supporting event/condition enums; compatibility test with `opc-sbi` added. Remaining: broader TS 29.5xx interfaces and schema-sanitization hardening. | | GAP-PROTO-005 | 005 | PFCP codec (TS 29.244) | closed (codec scope) — [`CONFORMANCE.md`](../crates/opc-proto-pfcp/CONFORMANCE.md) | `opc-proto-pfcp` | Closed (June 2026): `opc-proto-pfcp` ships a header + IE TLV layer, heartbeat/association/session messages, and typed session-management IEs including QFI, Gate Status, MBR, GBR, and QER grouping. Spec-byte fixtures, byte-exact round-trips, negative tests, and a fuzz target are in place. Full message semantic validation and non-SMF/UPF message types are outside the codec boundary. | | GAP-PROTO-006 | 005 | NAS v2 codec: first-CNF body dispatch and NAS security hooks | closed (scoped) — [`opc-proto-nas`](../crates/opc-proto-nas/) | `opc-proto-nas` | Closed (June 2026): Added IE-level decoding for Registration Request/Accept and Security Mode Command/Complete, named raw-preserving 5GMM/5GSM first-CNF body dispatch, optional-IE raw preservation, BCD unpacking for PLMN/routing indicator/IMEI/IMEISV, NAS COUNT/replay helpers, `opc-key` session key-handle validation, and caller-provided NAS integrity/ciphering hooks. In-tree null algorithms cover NIA0/NEA0; concrete NIA1/2/3 and NEA1/2/3 implementations and NAS procedure state machines remain external to the codec. | -| GAP-PROTO-007 | 005 | GTPv2-C S2b typed subset | partial — [`CONFORMANCE.md`](../crates/opc-proto-gtpv2c/CONFORMANCE.md) | `opc-proto-gtpv2c` | Partially closed (July 2026): `opc-proto-gtpv2c` ships an experimental S2b subset with raw-preserving GTPv2-C header/IE handling, typed Echo/Create/Modify/Delete session views, and complete claimed triggered Create/Update/Delete Bearer message shapes. ProcedureAware receive applies TS 29.274 first-occurrence singleton semantics per exact top-level/Bearer-Context-instance scope, classifies crate-known type/instance keys with a message grammar before value decoding, applies explicit S2b applicability for exact endpoint roles, preserves genuinely unknown optional keys, retains bounded value-free duplicate evidence, and truncates declared lists at their table bounds while canonical builders remain duplicate-strict; typed projections enforce endpoint value semantics and correlation. S2b Create Session sends the requested family only in PAA, exposes explicit dynamic/static allocation constructors, rejects top-level PDN Type, and discards that unexpected known IE on receive. Its conditional intent now distinguishes subscriber/UICC-less emergency identity, records AAA/HSS MSISDN provenance, types charging/trace/WLAN/UE-NAT context, and separates the optional Create-only ePDG IKEv2 endpoint from the UE endpoint. Delete Session requires the S2b UE Local IP and types its procedure-specific UDP/TCP instances, WLAN context, and Diameter/IKEv2 release cause. Accepted Create Session Responses require and project the PGW S2b control F-TEID at instance 1/interface type 32 rather than request-side Sender F-TEID instance 0. S2b Modify Bearer models the UE-initiated IPsec tunnel update with independently optional typed WLAN location/timestamp, a Fixed Broadband local-IP/conditional-UDP endpoint, first-occurrence receive, discard of the non-S2b Bearer Context shape, and Cause/sequence/TEID response correlation. Dedicated-bearer coverage uses the canonical shared TFT codec, validates Create-new/uplink TFT semantics, typed Bearer QoS/ARP and standardized QCI rate rules, APN-AMBR, S2b-U F-TEID roles, partial outcomes, exact per-bearer correlation, and Message Priority propagation. Bounded generation-safe triggered transactions fence timed-out work until explicit cancellation acknowledgement, prevent duplicate side effects, and replay exact committed bytes. Hostile-input tests, conformance fixtures, and fuzz seeds cover the declared boundary. Remaining work includes other S2b procedures/IEs outside the declared matrix, independent capture provenance where used, and downstream product UDP, persistence, IKEv2 Child-SA lifecycle, and dataplane policy outside the SDK boundary. | +| GAP-PROTO-007 | 005 | GTPv2-C S2b typed subset | partial — [`CONFORMANCE.md`](../crates/opc-proto-gtpv2c/CONFORMANCE.md) | `opc-proto-gtpv2c` | Partially closed (July 2026): `opc-proto-gtpv2c` ships an experimental S2b subset with raw-preserving GTPv2-C header/IE handling, typed Echo/Create/Modify/Delete session views, and complete claimed triggered Create/Update/Delete Bearer message shapes. ProcedureAware receive applies TS 29.274 first-occurrence singleton semantics per exact top-level/Bearer-Context-instance scope, classifies crate-known type/instance keys with a message grammar before value decoding, applies explicit S2b applicability for exact endpoint roles, preserves genuinely unknown optional keys, retains bounded value-free duplicate evidence, and truncates declared lists at their table bounds while canonical builders remain duplicate-strict; typed projections enforce endpoint value semantics and correlation. S2b Create Session sends the requested family only in PAA, exposes explicit dynamic/static allocation constructors, rejects top-level PDN Type, and discards that unexpected known IE on receive. Its conditional intent now distinguishes subscriber/UICC-less emergency identity, records AAA/HSS MSISDN provenance, types charging/trace/WLAN/UE-NAT context, and separates the optional Create-only ePDG IKEv2 endpoint from the UE endpoint. Delete Session requires the S2b UE Local IP and types its procedure-specific UDP/TCP instances, WLAN context, and Diameter/IKEv2 release cause. Accepted Create Session Responses default to PGW control instance 1/type 32 and user-plane type 33; a finite one-shot receive policy can independently add only standardized S5/S8 control type 7 and user-plane type 5 while builders and all no-policy entry points remain strict. S2b Modify Bearer models the UE-initiated IPsec tunnel update with independently optional typed WLAN location/timestamp, a Fixed Broadband local-IP/conditional-UDP endpoint, first-occurrence receive, discard of the non-S2b Bearer Context shape, and Cause/sequence/TEID response correlation. Dedicated-bearer coverage uses the canonical shared TFT codec, validates Create-new/uplink TFT semantics, typed Bearer QoS/ARP and standardized QCI rate rules, APN-AMBR, S2b-U F-TEID roles, partial outcomes, exact per-bearer correlation, and Message Priority propagation. Bounded generation-safe triggered transactions fence timed-out work until explicit cancellation acknowledgement, prevent duplicate side effects, and replay exact committed bytes. Hostile-input tests, conformance fixtures, and fuzz seeds cover the declared boundary. Remaining work includes other S2b procedures/IEs outside the declared matrix, independent capture provenance where used, and downstream product UDP, persistence, IKEv2 Child-SA lifecycle, and dataplane policy outside the SDK boundary. | | GAP-PROTO-008 | 005 | IKEv2 codec scaffold | partial — [`CONFORMANCE.md`](../crates/opc-proto-ikev2/CONFORMANCE.md) | `opc-proto-ikev2`, `opc-ipsec-xfrm` | Partially closed (July 2026): `opc-proto-ikev2` ships an experimental IKEv2 fixed-header and generic payload-chain scaffold, unknown payload preservation, typed executable SA_INIT profiles and product-neutral proposal selection, a strict opened IKE-SA rekey `CREATE_CHILD_SA` request/selection/exact-response boundary, PRF-HMAC-SHA2-256/384/512 initial/rekey/Child key derivation and restore, AES-GCM-128/192/256 plus AES-CBC-128/192/256 with SHA2 integrity for `SK` and `SKF`, typed ENCR_NULL authenticated-only ESP Child-SA negotiation/restore and zero-encryption-key KEYMAT, NAT-D semantic evaluation, typed IKE_AUTH cleartext helpers for ID/AUTH/EAP/CP/SA/TS/Notify/Delete payloads, shared-key AUTH MIC computation/verification, product-neutral Child SA selection intent, RFC 7383 fragment framing and decrypted-fragment reassembly helpers, and typed TS 24.302 R17 multiple-bearer notifications plus strict opened-payload primitives for new non-rekey dedicated-bearer `CREATE_CHILD_SA`, modification, deletion, and response correlation. RFC/independent vectors, a literal synthetic capture-shaped SA_INIT-to-protected-IKE_AUTH proof, byte-exact AEAD and encrypt-then-MAC IKE-SA rekey vectors, and hostile-input tests cover the claimed crypto mechanisms; registered fuzz targets cover the message/raw/dedicated-bearer codec boundaries listed in the crate conformance matrix. `opc-ipsec-xfrm` adds an opt-in exact mapper from negotiated ESP Child SA intent to bidirectional XFRM SA/policy install requests, including Linux's canonical zero-key NULL cipher plus separate auth representation, and an exact current-upstream single-SA outer-endpoint/NAT-T relocation primitive with a collision-free missing-SA capability probe. A privileged namespace test proves bidirectional authenticated-only ESP delivery and tamper rejection; no SDK policy enables or prefers ENCR_NULL. The relocation primitive is SA-only, not cancellation-safe once polled, and requires product-owned authenticated signalling, policy coordination, writer serialization, cancellation/process-loss reconciliation, supporting kernel UAPI, and live mobility evidence. Remaining work includes broader remaining payload-body coverage, independent-peer fixture provenance where used, and downstream IKE SA/EAP-AKA state, retransmission timers/caches and fragment queues, SPI allocation, Child SA lifecycle/XFRM policy, and carrier qualification outside the SDK boundary. | | GAP-PROTO-009 | 005 | Diameter base scaffold (RFC 6733) | partial — [`CONFORMANCE.md`](../crates/opc-proto-diameter/CONFORMANCE.md) | `opc-proto-diameter`, `opc-testbed` | Partially closed (July 2026): `opc-proto-diameter` ships an experimental Diameter base-protocol scaffold with RFC 6733 header/AVP framing, raw-preserving message/AVP storage, AVP-region validation, dictionary metadata, feature-gated base peer procedure helpers for CER/CEA, DWR/DWA, and DPR/DPA, typed Rf accounting helpers, typed SWm Diameter-EAP DER/DEA helpers with ePDG-subset semantic validation, and typed SWm STR/STA, ASR/ASA, RAR/RAA, and AAR/AAA lifecycle helpers. The authorization slice includes typed RFC 6733 Authorization-Lifetime/Auth-Grace-Period and TS 29.273 AAA Session-Timeout with command-authoritative cardinality and cross-field validation. Requests retain request-bound identifiers, checked 5005 omission provenance, typed vendor state, exact present session/user/Proxy-Info state, and authenticated connection-generation binding with explicit direct/routed logical-Origin policy; failover retransmission atomically replaces the Hop-by-Hop Identifier and connection binding while preserving End-to-End duplicate identity. Generic E-bit answers may skip logical-Origin policy but remain connection- and transaction-bound. Fully modeled answer emission, deterministic committed-answer reconstruction, maintained-state administrative STR derivation, a public RAR→RAA→AAR→AAA type-state sequence, command-authoritative cardinality, dictionary-validated additional values, and bounded RFC 7683 overload/RFC 8583 Load groups are included. A compiler-external deterministic public-API fixture proves DER/DEA→RAR/RAA→AAR/AAA→STR/STA and a separate DER/DEA→ASR/ASA→derived administrative STR/STA, closing #351's requested generic SWm lifecycle scope without importing product policy. Hostile-input tests, independently authored fixtures, fixture/corpus replay, registered fuzz targets, conformance notes, and an ePDG SDK composition harness in `opc-testbed` are in place. Remaining work includes broader typed application helpers beyond the current Rf/SWm subsets, additional independently sourced fixture intake, and downstream product realm routing/transport/AAA/session-authority behavior outside the SDK boundary. | From a27449e7c71c7ea85f95f810b6ae02aa583d3b77 Mon Sep 17 00:00:00 2001 From: VerifiedOrganic Date: Sat, 15 Aug 2026 23:05:35 -0600 Subject: [PATCH 2/2] fix(gtpv2c): redact S2b response builder debug Signed-off-by: VerifiedOrganic --- crates/opc-proto-gtpv2c/src/s2b.rs | 29 +++++++++- .../tests/s2b_profile_builders.rs | 54 ++++++++++++++++--- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/crates/opc-proto-gtpv2c/src/s2b.rs b/crates/opc-proto-gtpv2c/src/s2b.rs index 561f6efc..c2027143 100644 --- a/crates/opc-proto-gtpv2c/src/s2b.rs +++ b/crates/opc-proto-gtpv2c/src/s2b.rs @@ -494,7 +494,7 @@ impl fmt::Debug for S2bCreateSessionRequest<'_> { } /// Input for building an accepted S2b Production Profile v1 Create Session Response. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct S2bCreateSessionAcceptedResponse<'a> { /// GTPv2-C sequence number. pub sequence_number: u32, @@ -517,8 +517,21 @@ pub struct S2bCreateSessionAcceptedResponse<'a> { pub additional_ies: Vec>, } +impl fmt::Debug for S2bCreateSessionAcceptedResponse<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("S2bCreateSessionAcceptedResponse") + .field("sequence_number", &self.sequence_number) + .field("response_teid_present", &true) + .field("pgw_control_f_teid_present", &true) + .field("bearer_context_present", &true) + .field("additional_ie_count", &self.additional_ies.len()) + .finish() + } +} + /// Input for building a rejected S2b Production Profile v1 Create Session Response. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct S2bCreateSessionRejectedResponse<'a> { /// GTPv2-C sequence number. pub sequence_number: u32, @@ -532,6 +545,18 @@ pub struct S2bCreateSessionRejectedResponse<'a> { pub additional_ies: Vec>, } +impl fmt::Debug for S2bCreateSessionRejectedResponse<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("S2bCreateSessionRejectedResponse") + .field("sequence_number", &self.sequence_number) + .field("response_teid_present", &true) + .field("cause", &self.cause) + .field("additional_ie_count", &self.additional_ies.len()) + .finish() + } +} + /// Legacy bearer-context-shaped Modify Bearer input. /// /// This shape belongs to S4/S11/S5/S8 procedures and is not valid as an S2b diff --git a/crates/opc-proto-gtpv2c/tests/s2b_profile_builders.rs b/crates/opc-proto-gtpv2c/tests/s2b_profile_builders.rs index 06331d36..1722c7f8 100644 --- a/crates/opc-proto-gtpv2c/tests/s2b_profile_builders.rs +++ b/crates/opc-proto-gtpv2c/tests/s2b_profile_builders.rs @@ -6,14 +6,15 @@ use opc_proto_gtpv2c::{ s2b_ue_ipsec_tunnel_update_request, s2b_update_bearer_request, s2b_update_bearer_response, AccessPointName, AggregateMaximumBitRate, BearerContext, Cause, CauseValue, EpsBearerId, FullyQualifiedTeid, MessageDirection, PdnAddressAllocation, PdnType, PdnTypeValue, PlmnId, - RatType, RatTypeValue, Recovery, S2bCreateSessionAcceptedResponse, S2bCreateSessionContext, - S2bCreateSessionIdentity, S2bCreateSessionRejectedResponse, S2bCreateSessionRequest, - S2bDeleteSessionContext, S2bDeleteSessionRequest, S2bDeleteSessionResponse, S2bMessage, - S2bModifyBearerResponse, S2bProfileBuildError, S2bUeEndpoint, S2bUeIpsecTunnelUpdateEndpoint, - S2bUeIpsecTunnelUpdateRequest, S2bUpdateBearerRequest, S2bUpdateBearerRequestContext, - S2bUpdateBearerResponse, S2bUpdateBearerResult, SelectionMode, SelectionModeValue, - ServingNetwork, TbcdDigits, TypedIe, TypedIeValue, IE_TYPE_F_TEID, IE_TYPE_PDN_TYPE, - INTERFACE_TYPE_S2B_PGW_GTP_C, INTERFACE_TYPE_S2B_U_PGW_GTP_U, + RatType, RatTypeValue, RawIe, Recovery, S2bCreateSessionAcceptedResponse, + S2bCreateSessionContext, S2bCreateSessionIdentity, S2bCreateSessionRejectedResponse, + S2bCreateSessionRequest, S2bDeleteSessionContext, S2bDeleteSessionRequest, + S2bDeleteSessionResponse, S2bMessage, S2bModifyBearerResponse, S2bProfileBuildError, + S2bUeEndpoint, S2bUeIpsecTunnelUpdateEndpoint, S2bUeIpsecTunnelUpdateRequest, + S2bUpdateBearerRequest, S2bUpdateBearerRequestContext, S2bUpdateBearerResponse, + S2bUpdateBearerResult, SelectionMode, SelectionModeValue, ServingNetwork, TbcdDigits, TypedIe, + TypedIeValue, IE_TYPE_F_TEID, IE_TYPE_PDN_TYPE, INTERFACE_TYPE_S2B_PGW_GTP_C, + INTERFACE_TYPE_S2B_U_PGW_GTP_U, }; use opc_protocol::{DecodeContext, DecodeErrorCode, Encode, EncodeContext, ValidationLevel}; @@ -246,6 +247,43 @@ fn create_session_response_builders_project_stable_summaries() { assert_eq!(rejected_summary.cause, CauseValue::InvalidMessageFormat); } +#[test] +fn create_session_response_builder_inputs_have_value_free_debug() { + const PRIVATE_VALUE: &[u8] = b"builder-private-sentinel"; + let private_ie = || TypedIe { + instance: 0, + value: TypedIeValue::Raw(RawIe { + ie_type: 250, + instance: 0, + spare: 0, + value: PRIVATE_VALUE, + }), + }; + + let accepted = S2bCreateSessionAcceptedResponse { + sequence_number: 0x010203, + response_teid: 0xf1e2_d3c4, + pgw_control_f_teid: pgw_control_f_teid(0xa1b2_c3d4), + bearer_context: accepted_bearer_context(5, 0x91a2_b3c4), + additional_ies: vec![private_ie()], + }; + assert_eq!( + format!("{accepted:?}"), + "S2bCreateSessionAcceptedResponse { sequence_number: 66051, response_teid_present: true, pgw_control_f_teid_present: true, bearer_context_present: true, additional_ie_count: 1 }" + ); + + let rejected = S2bCreateSessionRejectedResponse { + sequence_number: 0x040506, + response_teid: 0xe1d2_c3b4, + cause: CauseValue::SystemFailure, + additional_ies: vec![private_ie()], + }; + assert_eq!( + format!("{rejected:?}"), + "S2bCreateSessionRejectedResponse { sequence_number: 263430, response_teid_present: true, cause: SystemFailure, additional_ie_count: 1 }" + ); +} + #[test] fn create_session_accepted_response_builder_rejects_invalid_control_endpoints() { let invalid_endpoints = [