///|
/// Query, statistics, engineering audit and RFC 3264 offer/answer analysis.
pub struct SessionStats {
media_count : Int
active_media_count : Int
rejected_media_count : Int
session_attribute_count : Int
media_attribute_count : Int
rtp_payload_count : Int
connection_count : Int
bandwidth_count : Int
timing_count : Int
unknown_attribute_count : Int
}
///|
pub fn SessionStats::media_count(self : SessionStats) -> Int {
self.media_count
}
///|
pub fn SessionStats::active_media_count(self : SessionStats) -> Int {
self.active_media_count
}
///|
pub fn SessionStats::rejected_media_count(self : SessionStats) -> Int {
self.rejected_media_count
}
///|
pub fn SessionStats::session_attribute_count(self : SessionStats) -> Int {
self.session_attribute_count
}
///|
pub fn SessionStats::media_attribute_count(self : SessionStats) -> Int {
self.media_attribute_count
}
///|
pub fn SessionStats::rtp_payload_count(self : SessionStats) -> Int {
self.rtp_payload_count
}
///|
pub fn SessionStats::connection_count(self : SessionStats) -> Int {
self.connection_count
}
///|
pub fn SessionStats::bandwidth_count(self : SessionStats) -> Int {
self.bandwidth_count
}
///|
pub fn SessionStats::timing_count(self : SessionStats) -> Int {
self.timing_count
}
///|
pub fn SessionStats::unknown_attribute_count(self : SessionStats) -> Int {
self.unknown_attribute_count
}
///|
/// Computes structural statistics without altering the document.
pub fn statistics(document : SessionDescription) -> SessionStats {
let mut active = 0
let mut rejected = 0
let mut media_attributes = 0
let mut payloads = 0
let mut connections = if document.connection() is Some(_) { 1 } else { 0 }
let mut bandwidths = document.bandwidths().length()
let mut unknown = 0
for item in document.attributes() {
if !is_registered_attribute(item.name()) {
unknown = unknown + 1
}
}
for media in document.media_descriptions() {
if media.is_rejected() {
rejected = rejected + 1
} else {
active = active + 1
}
media_attributes = media_attributes + media.attributes().length()
payloads = payloads + media.formats().length()
connections = connections + media.connections().length()
bandwidths = bandwidths + media.bandwidths().length()
for item in media.attributes() {
if !is_registered_attribute(item.name()) {
unknown = unknown + 1
}
}
}
{
media_count: document.media_count(),
active_media_count: active,
rejected_media_count: rejected,
session_attribute_count: document.attributes().length(),
media_attribute_count: media_attributes,
rtp_payload_count: payloads,
connection_count: connections,
bandwidth_count: bandwidths,
timing_count: document.timings().length(),
unknown_attribute_count: unknown,
}
}
///|
pub fn SessionStats::to_text(self : SessionStats) -> String {
"media=\{self.media_count} active=\{self.active_media_count} rejected=\{self.rejected_media_count} attributes=\{self.session_attribute_count + self.media_attribute_count} payloads=\{self.rtp_payload_count} connections=\{self.connection_count} bandwidths=\{self.bandwidth_count} timings=\{self.timing_count} extensions=\{self.unknown_attribute_count}"
}
///|
/// Returns every media description with a matching media token.
pub fn media_by_type(
document : SessionDescription,
media_type : String,
) -> Array[MediaDescription] {
let out : Array[MediaDescription] = Array::new()
for media in document.media_descriptions() {
if media.media().equal_ignore_ascii_case(media_type) {
out.push(media)
}
}
out
}
///|
/// Returns every non-rejected media description.
pub fn active_media(document : SessionDescription) -> Array[MediaDescription] {
let out : Array[MediaDescription] = Array::new()
for media in document.media_descriptions() {
if !media.is_rejected() {
out.push(media)
}
}
out
}
///|
/// Finds a media description by its `a=mid` value.
pub fn media_by_mid(
document : SessionDescription,
mid : String,
) -> MediaDescription? {
for media in document.media_descriptions() {
match media.attribute_value("mid") {
Some(value) => if value == mid { return Some(media) }
None => ()
}
}
None
}
///|
/// Returns the distinct codec names advertised by all valid rtpmap lines.
pub fn codec_names(document : SessionDescription) -> Array[String] {
let out : Array[String] = Array::new()
for media in document.media_descriptions() {
for mapping in media.rtp_maps() {
let name = mapping.encoding_name().to_upper()
if !string_in(out, name) {
out.push(name)
}
}
}
out
}
///|
pub enum AuditSeverity {
High
Medium
Low
}
///|
pub struct AuditFinding {
severity : AuditSeverity
code : String
scope : String
message : String
}
///|
pub struct AuditReport {
findings : Array[AuditFinding]
}
///|
fn audit_finding(
severity : AuditSeverity,
code : String,
scope : String,
message : String,
) -> AuditFinding {
{ severity, code, scope, message }
}
///|
pub fn AuditSeverity::to_string(self : AuditSeverity) -> String {
match self {
High => "high"
Medium => "medium"
Low => "low"
}
}
///|
pub fn AuditFinding::severity(self : AuditFinding) -> AuditSeverity {
self.severity
}
///|
pub fn AuditFinding::code(self : AuditFinding) -> String {
self.code
}
///|
pub fn AuditFinding::scope(self : AuditFinding) -> String {
self.scope
}
///|
pub fn AuditFinding::message(self : AuditFinding) -> String {
self.message
}
///|
pub fn AuditReport::findings(self : AuditReport) -> Array[AuditFinding] {
self.findings
}
///|
pub fn AuditReport::finding_count(self : AuditReport) -> Int {
self.findings.length()
}
///|
pub fn AuditReport::high_count(self : AuditReport) -> Int {
let mut count = 0
for item in self.findings {
if item.severity() is High {
count = count + 1
}
}
count
}
///|
pub fn AuditReport::medium_count(self : AuditReport) -> Int {
let mut count = 0
for item in self.findings {
if item.severity() is Medium {
count = count + 1
}
}
count
}
///|
pub fn AuditReport::low_count(self : AuditReport) -> Int {
let mut count = 0
for item in self.findings {
if item.severity() is Low {
count = count + 1
}
}
count
}
///|
fn push_finding(
findings : Array[AuditFinding],
severity : AuditSeverity,
code : String,
scope : String,
message : String,
) -> Unit {
findings.push(audit_finding(severity, code, scope, message))
}
///|
/// Performs operational checks that are deliberately stricter than RFC 8866.
pub fn audit(document : SessionDescription) -> AuditReport {
let findings : Array[AuditFinding] = Array::new()
let validation = validate(document)
if validation.error_count() > 0 {
push_finding(
findings,
High,
"AUDIT_VALIDATION_ERRORS",
"session",
"document has RFC validation errors",
)
}
if document.encryption_key() is Some(_) {
push_finding(
findings,
High,
"AUDIT_OBSOLETE_KEY",
"session",
"obsolete k= material should not be used",
)
}
if document.connection() is Some(_) &&
document.media_descriptions().length() > 1 {
push_finding(
findings,
Low,
"AUDIT_SHARED_CONNECTION",
"session",
"multiple media sections inherit one connection address; verify this is intentional",
)
}
if document.session_name() == "-" {
push_finding(
findings,
Low,
"AUDIT_PLACEHOLDER_NAME",
"session",
"session name is the interoperable placeholder '-' rather than a descriptive title",
)
}
if document.attributes_named("tool").length() == 0 {
push_finding(
findings,
Low,
"AUDIT_NO_TOOL",
"session",
"a=tool can help operational diagnostics",
)
}
let seen_mids : Array[String] = Array::new()
for index, media in document.media_descriptions() {
let scope = "media[\{index}]"
if media.attributes_named("mid").length() == 0 && document.media_count() > 1 {
push_finding(
findings,
Medium,
"AUDIT_NO_MID",
scope,
"multi-media sessions are easier to correlate when every section has a unique mid",
)
}
match media.attribute_value("mid") {
Some(value) =>
if string_in(seen_mids, value) {
push_finding(
findings,
High,
"AUDIT_DUPLICATE_MID",
scope,
"mid must be unique across media descriptions",
)
} else {
seen_mids.push(value)
}
None => ()
}
if is_rtp_protocol(media.protocol()) &&
media.attributes_named("rtcp-mux").length() == 0 {
push_finding(
findings,
Low,
"AUDIT_NO_RTCP_MUX",
scope,
"RTP media does not advertise RTCP multiplexing",
)
}
if media.direction(document.attributes()) is Unspecified {
push_finding(
findings,
Low,
"AUDIT_IMPLICIT_DIRECTION",
scope,
"direction is implicit sendrecv",
)
}
if media.port() != 0 && media.formats().length() > 16 {
push_finding(
findings,
Medium,
"AUDIT_MANY_FORMATS",
scope,
"large format lists may reduce interoperability",
)
}
}
{ findings, }
}
///|
pub fn AuditReport::to_text(self : AuditReport) -> String {
let sb = StringBuilder()
sb.write_string("audit: high=")
sb.write_string(self.high_count().to_string())
sb.write_string(" medium=")
sb.write_string(self.medium_count().to_string())
sb.write_string(" low=")
sb.write_string(self.low_count().to_string())
for item in self.findings {
sb.write_string("\n[")
sb.write_string(item.severity().to_string())
sb.write_string("] ")
sb.write_string(item.code())
sb.write_string(" ")
sb.write_string(item.scope())
sb.write_string(": ")
sb.write_string(item.message())
}
sb.to_string()
}
///|
pub struct OfferAnswerIssue {
code : String
media_index : Int
message : String
reference : String
}
///|
pub struct OfferAnswerReport {
compatible : Bool
issues : Array[OfferAnswerIssue]
accepted_media : Int
rejected_media : Int
}
///|
fn offer_answer_issue(
code : String,
media_index : Int,
message : String,
reference : String,
) -> OfferAnswerIssue {
{ code, media_index, message, reference }
}
///|
pub fn OfferAnswerIssue::code(self : OfferAnswerIssue) -> String {
self.code
}
///|
pub fn OfferAnswerIssue::media_index(self : OfferAnswerIssue) -> Int {
self.media_index
}
///|
pub fn OfferAnswerIssue::message(self : OfferAnswerIssue) -> String {
self.message
}
///|
pub fn OfferAnswerIssue::reference(self : OfferAnswerIssue) -> String {
self.reference
}
///|
pub fn OfferAnswerReport::compatible(self : OfferAnswerReport) -> Bool {
self.compatible
}
///|
pub fn OfferAnswerReport::issues(
self : OfferAnswerReport,
) -> Array[OfferAnswerIssue] {
self.issues
}
///|
pub fn OfferAnswerReport::issue_count(self : OfferAnswerReport) -> Int {
self.issues.length()
}
///|
pub fn OfferAnswerReport::accepted_media(self : OfferAnswerReport) -> Int {
self.accepted_media
}
///|
pub fn OfferAnswerReport::rejected_media(self : OfferAnswerReport) -> Int {
self.rejected_media
}
///|
fn direction_compatible(offer : Direction, answer : Direction) -> Bool {
match offer {
SendOnly => answer is RecvOnly || answer is Inactive
RecvOnly => answer is SendOnly || answer is Inactive
Inactive => answer is Inactive
SendRecv =>
answer is SendRecv ||
answer is SendOnly ||
answer is RecvOnly ||
answer is Inactive
Unspecified => true
}
}
///|
fn rtp_mapping_compatible(
offer_media : MediaDescription,
answer_media : MediaDescription,
format : String,
) -> Bool {
let payload = match parse_non_negative_int(format) {
Some(value) => value
None => return true
}
let offered = offer_media.rtp_map_for(payload)
let answered = answer_media.rtp_map_for(payload)
match (offered, answered) {
(Some(left), Some(right)) =>
left.encoding_name().equal_ignore_ascii_case(right.encoding_name()) &&
left.clock_rate() == right.clock_rate() &&
left.encoding_parameters() == right.encoding_parameters()
_ => true
}
}
///|
/// Performs RFC 3264 structural offer/answer checks without network behavior.
pub fn analyze_offer_answer(
offer : SessionDescription,
answer : SessionDescription,
) -> OfferAnswerReport {
let issues : Array[OfferAnswerIssue] = Array::new()
let offer_media = offer.media_descriptions()
let answer_media = answer.media_descriptions()
if answer_media.length() != offer_media.length() {
issues.push(
offer_answer_issue(
"OA_MEDIA_COUNT", -1, "answer must contain the same number of m= lines as the offer",
"RFC 3264 §6",
),
)
}
let count = if offer_media.length() < answer_media.length() {
offer_media.length()
} else {
answer_media.length()
}
let mut accepted = 0
let mut rejected = 0
for index = 0; index < count; index = index + 1 {
let offered = offer_media[index]
let answered = answer_media[index]
if !offered.media().equal_ignore_ascii_case(answered.media()) {
issues.push(
offer_answer_issue(
"OA_MEDIA_ORDER", index, "answer media type does not correspond to the offered m= line",
"RFC 3264 §6",
),
)
}
if !offered.protocol().equal_ignore_ascii_case(answered.protocol()) &&
answered.port() != 0 {
issues.push(
offer_answer_issue(
"OA_PROTOCOL_CHANGED", index, "accepted answer changed the transport protocol",
"RFC 3264 §6",
),
)
}
if answered.port() == 0 {
rejected = rejected + 1
continue
}
accepted = accepted + 1
for format in answered.formats() {
if !offered.has_format(format) {
issues.push(
offer_answer_issue(
"OA_FORMAT_NOT_OFFERED", index, "answer selected a format absent from the offer",
"RFC 3264 §6.1",
),
)
} else if !rtp_mapping_compatible(offered, answered, format) {
issues.push(
offer_answer_issue(
"OA_RTPMAP_CHANGED", index, "answer changed encoding identity for a payload type",
"RFC 3264 §6.1",
),
)
}
}
let offer_direction = offered.direction(offer.attributes())
let answer_direction = answered.direction(answer.attributes())
if !direction_compatible(offer_direction, answer_direction) {
issues.push(
offer_answer_issue(
"OA_DIRECTION", index, "answer direction is incompatible with the offer",
"RFC 3264 §6.1",
),
)
}
}
{
compatible: issues.length() == 0,
issues,
accepted_media: accepted,
rejected_media: rejected,
}
}
///|
pub fn OfferAnswerReport::to_text(self : OfferAnswerReport) -> String {
let sb = StringBuilder()
sb.write_string(
if self.compatible {
"offer-answer: compatible"
} else {
"offer-answer: incompatible"
},
)
sb.write_string(" accepted=")
sb.write_string(self.accepted_media.to_string())
sb.write_string(" rejected=")
sb.write_string(self.rejected_media.to_string())
for item in self.issues {
sb.write_string("\n[")
sb.write_string(item.code())
sb.write_string("] media=")
sb.write_string(item.media_index().to_string())
sb.write_string(": ")
sb.write_string(item.message())
}
sb.to_string()
}