Skip to main content

termivar_scanner/
openapi_review.rs

1//! Transport-neutral, bounded OpenAPI document classification and cataloging.
2//!
3//! This module deliberately owns no transport, authority, request planner, or
4//! executor. It reduces a bounded JSON document to deterministic protocol
5//! metadata. Prose, examples, defaults, raw security-scheme names, and server
6//! values are never retained.
7
8use std::{collections::BTreeSet, fmt};
9
10use serde::{
11    de::{MapAccess, SeqAccess, Visitor},
12    Deserialize, Deserializer,
13};
14use serde_json::{Map, Value};
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17use url::Url;
18
19pub const MAX_OPENAPI_DOCUMENT_BYTES: usize = 2 * 1024 * 1024;
20pub const MAX_OPENAPI_JSON_DEPTH: usize = 64;
21pub const MAX_OPENAPI_JSON_NODES: usize = 100_000;
22pub const MAX_OPENAPI_OBJECT_MEMBERS: usize = 50_000;
23pub const MAX_OPENAPI_ARRAY_LENGTH: usize = 4_096;
24pub const MAX_OPENAPI_STRING_BYTES: usize = 256 * 1024;
25pub const MAX_OPENAPI_PATHS: usize = 4_096;
26pub const MAX_OPENAPI_OPERATIONS: usize = 4_096;
27pub const MAX_OPENAPI_PARAMETERS_PER_OPERATION: usize = 64;
28pub const MAX_OPENAPI_MEDIA_TYPES_PER_OPERATION: usize = 32;
29pub const MAX_MEDIA_ENTRIES_PER_OPERATION: usize = 64;
30pub const MAX_OPENAPI_RESPONSES_PER_OPERATION: usize = 64;
31pub const MAX_OPENAPI_SECURITY_REQUIREMENTS: usize = 64;
32pub const MAX_OPENAPI_SERVERS: usize = 16;
33pub const MAX_OPENAPI_PATH_BYTES: usize = 2_048;
34pub const MAX_OPENAPI_PATH_SEGMENTS: usize = 256;
35pub const MAX_OPENAPI_TOKEN_BYTES: usize = 256;
36pub const OPENAPI_CATALOG_ALGORITHM: &str = "security.openapi-catalog/v1";
37
38/// Checked parser ceilings. Callers may narrow, but cannot widen, hard limits.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub struct OpenApiDocumentLimits {
41    pub document_bytes: usize,
42    pub depth: usize,
43    pub nodes: usize,
44    pub object_members: usize,
45    pub array_length: usize,
46    pub string_bytes: usize,
47    pub paths: usize,
48    pub operations: usize,
49}
50
51impl OpenApiDocumentLimits {
52    pub fn checked(self) -> Result<Self, OpenApiReviewError> {
53        if self.document_bytes == 0
54            || self.document_bytes > MAX_OPENAPI_DOCUMENT_BYTES
55            || self.depth == 0
56            || self.depth > MAX_OPENAPI_JSON_DEPTH
57            || self.nodes == 0
58            || self.nodes > MAX_OPENAPI_JSON_NODES
59            || self.object_members == 0
60            || self.object_members > MAX_OPENAPI_OBJECT_MEMBERS
61            || self.array_length == 0
62            || self.array_length > MAX_OPENAPI_ARRAY_LENGTH
63            || self.string_bytes == 0
64            || self.string_bytes > MAX_OPENAPI_STRING_BYTES
65            || self.paths == 0
66            || self.paths > MAX_OPENAPI_PATHS
67            || self.operations == 0
68            || self.operations > MAX_OPENAPI_OPERATIONS
69        {
70            return Err(OpenApiReviewError::InvalidLimits);
71        }
72        Ok(self)
73    }
74}
75
76impl Default for OpenApiDocumentLimits {
77    fn default() -> Self {
78        Self {
79            document_bytes: MAX_OPENAPI_DOCUMENT_BYTES,
80            depth: MAX_OPENAPI_JSON_DEPTH,
81            nodes: MAX_OPENAPI_JSON_NODES,
82            object_members: MAX_OPENAPI_OBJECT_MEMBERS,
83            array_length: MAX_OPENAPI_ARRAY_LENGTH,
84            string_bytes: MAX_OPENAPI_STRING_BYTES,
85            paths: MAX_OPENAPI_PATHS,
86            operations: MAX_OPENAPI_OPERATIONS,
87        }
88    }
89}
90
91#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
92pub enum OpenApiDocumentKind {
93    OpenApi30,
94    OpenApi31,
95    Swagger20MetadataOnly,
96}
97
98#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
99pub enum OpenApiVersion {
100    OpenApi30,
101    OpenApi31,
102}
103
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub enum OpenApiParseOutcome {
106    Complete(OpenApiDocument),
107    Swagger20MetadataOnly,
108    UnsupportedVersion,
109    Malformed,
110    LimitExceeded,
111    TooLarge,
112}
113
114/// Classifies and reduces one repository- or host-retained document.
115///
116/// `document_origin` is used only to classify server metadata conservatively.
117/// It never grants authority, resolves references, performs I/O, or selects a
118/// request target. A runtime must authorize every selected URL independently.
119pub fn parse_openapi_document(body: &[u8], document_origin: &Url) -> OpenApiParseOutcome {
120    match parse_json_at_origin(body, OpenApiDocumentLimits::default(), document_origin) {
121        Ok(document) if document.kind == OpenApiDocumentKind::Swagger20MetadataOnly => {
122            OpenApiParseOutcome::Swagger20MetadataOnly
123        },
124        Ok(document) => OpenApiParseOutcome::Complete(document),
125        Err(OpenApiReviewError::DocumentSize) => OpenApiParseOutcome::TooLarge,
126        Err(OpenApiReviewError::MalformedJson | OpenApiReviewError::UnsupportedDocument) => {
127            OpenApiParseOutcome::Malformed
128        },
129        Err(OpenApiReviewError::UnsupportedVersion) => OpenApiParseOutcome::UnsupportedVersion,
130        Err(
131            OpenApiReviewError::InvalidLimits
132            | OpenApiReviewError::JsonLimit
133            | OpenApiReviewError::ExternalReference
134            | OpenApiReviewError::InvalidPaths
135            | OpenApiReviewError::InvalidOperation
136            | OpenApiReviewError::CatalogLimit,
137        ) => OpenApiParseOutcome::LimitExceeded,
138    }
139}
140
141fn parse_json_at_origin(
142    body: &[u8],
143    limits: OpenApiDocumentLimits,
144    origin: &Url,
145) -> Result<OpenApiDocument, OpenApiReviewError> {
146    let limits = limits.checked()?;
147    if body.is_empty() {
148        return Err(OpenApiReviewError::MalformedJson);
149    }
150    if body.len() > limits.document_bytes {
151        return Err(OpenApiReviewError::DocumentSize);
152    }
153    let mut deserializer = serde_json::Deserializer::from_slice(body);
154    let root = UniqueValue::deserialize(&mut deserializer)
155        .map_err(|_| OpenApiReviewError::MalformedJson)?
156        .0;
157    deserializer
158        .end()
159        .map_err(|_| OpenApiReviewError::MalformedJson)?;
160    validate_json_limits(&root, limits)?;
161    validate_references(&root)?;
162    parse_document(root, limits, origin)
163}
164
165impl OpenApiDocumentKind {
166    const fn token(self) -> &'static str {
167        match self {
168            Self::OpenApi30 => "openapi-3.0",
169            Self::OpenApi31 => "openapi-3.1",
170            Self::Swagger20MetadataOnly => "swagger-2.0-metadata-only",
171        }
172    }
173
174    pub const fn executable_catalog(self) -> bool {
175        !matches!(self, Self::Swagger20MetadataOnly)
176    }
177}
178
179#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
180pub enum OpenApiHttpMethod {
181    Get,
182    Put,
183    Post,
184    Delete,
185    Options,
186    Head,
187    Patch,
188    Trace,
189}
190
191impl OpenApiHttpMethod {
192    const ALL: [Self; 8] = [
193        Self::Get,
194        Self::Put,
195        Self::Post,
196        Self::Delete,
197        Self::Options,
198        Self::Head,
199        Self::Patch,
200        Self::Trace,
201    ];
202
203    const fn token(self) -> &'static str {
204        match self {
205            Self::Get => "get",
206            Self::Put => "put",
207            Self::Post => "post",
208            Self::Delete => "delete",
209            Self::Options => "options",
210            Self::Head => "head",
211            Self::Patch => "patch",
212            Self::Trace => "trace",
213        }
214    }
215}
216
217#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
218pub enum OpenApiParameterLocation {
219    Query,
220    Header,
221    Path,
222    Cookie,
223}
224
225impl OpenApiParameterLocation {
226    fn parse(value: &str) -> Option<Self> {
227        match value {
228            "query" => Some(Self::Query),
229            "header" => Some(Self::Header),
230            "path" => Some(Self::Path),
231            "cookie" => Some(Self::Cookie),
232            _ => None,
233        }
234    }
235
236    const fn token(self) -> &'static str {
237        match self {
238            Self::Query => "query",
239            Self::Header => "header",
240            Self::Path => "path",
241            Self::Cookie => "cookie",
242        }
243    }
244}
245
246#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
247pub enum OpenApiSecuritySchemeKind {
248    ApiKeyQuery,
249    ApiKeyHeader,
250    ApiKeyCookie,
251    HttpBasic,
252    HttpBearer,
253    HttpOther,
254    MutualTls,
255    OAuth2,
256    OpenIdConnect,
257    Unknown,
258}
259
260impl OpenApiSecuritySchemeKind {
261    const fn token(self) -> &'static str {
262        match self {
263            Self::ApiKeyQuery => "api-key-query",
264            Self::ApiKeyHeader => "api-key-header",
265            Self::ApiKeyCookie => "api-key-cookie",
266            Self::HttpBasic => "http-basic",
267            Self::HttpBearer => "http-bearer",
268            Self::HttpOther => "http-other",
269            Self::MutualTls => "mutual-tls",
270            Self::OAuth2 => "oauth2",
271            Self::OpenIdConnect => "openid-connect",
272            Self::Unknown => "unknown",
273        }
274    }
275}
276
277#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
278pub enum OpenApiServerKind {
279    ExactOrigin,
280    Relative,
281    CrossOrigin,
282    Templated,
283    Unsupported,
284}
285
286impl OpenApiServerKind {
287    const fn token(self) -> &'static str {
288        match self {
289            Self::ExactOrigin => "exact-origin",
290            Self::Relative => "relative-path",
291            Self::CrossOrigin => "cross-origin",
292            Self::Templated => "templated",
293            Self::Unsupported => "unsupported",
294        }
295    }
296}
297
298#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
299pub enum OpenApiCandidateTag {
300    ReadOnly,
301    BodyBearing,
302    Parameterized,
303    DeclaresSecurity,
304    DeclaresAnonymousAccess,
305    JsonRequest,
306    JsonResponse,
307    Deprecated,
308    AuthorizationReviewCandidate,
309    SqlInputCandidate,
310    SsrfUrlCandidate,
311    UploadCandidate,
312    OAuthCandidate,
313    BinaryResponse,
314}
315
316impl OpenApiCandidateTag {
317    const fn token(self) -> &'static str {
318        match self {
319            Self::ReadOnly => "read-only",
320            Self::BodyBearing => "body-bearing",
321            Self::Parameterized => "parameterized",
322            Self::DeclaresSecurity => "declares-security",
323            Self::DeclaresAnonymousAccess => "declares-anonymous-access",
324            Self::JsonRequest => "json-request",
325            Self::JsonResponse => "json-response",
326            Self::Deprecated => "deprecated",
327            Self::AuthorizationReviewCandidate => "authorization-review-candidate",
328            Self::SqlInputCandidate => "sql-input-candidate",
329            Self::SsrfUrlCandidate => "ssrf-url-candidate",
330            Self::UploadCandidate => "upload-candidate",
331            Self::OAuthCandidate => "oauth-candidate",
332            Self::BinaryResponse => "binary-response",
333        }
334    }
335}
336
337#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
338pub enum OpenApiSchemaPrimitiveKind {
339    String,
340    Integer,
341    Number,
342    Boolean,
343    Array,
344    Object,
345    Unknown,
346}
347
348#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
349pub enum OpenApiFormatClass {
350    Uri,
351    Url,
352    Hostname,
353    Uuid,
354    Email,
355    Password,
356    Binary,
357    Byte,
358    DateTime,
359    Date,
360    Other,
361    None,
362}
363
364impl OpenApiFormatClass {
365    const fn token(self) -> &'static str {
366        match self {
367            Self::Uri => "uri",
368            Self::Url => "url",
369            Self::Hostname => "hostname",
370            Self::Uuid => "uuid",
371            Self::Email => "email",
372            Self::Password => "password",
373            Self::Binary => "binary",
374            Self::Byte => "byte",
375            Self::DateTime => "date-time",
376            Self::Date => "date",
377            Self::Other => "other",
378            Self::None => "none",
379        }
380    }
381}
382
383#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
384pub enum OpenApiMediaFamily {
385    Json,
386    JsonSuffix,
387    FormUrlEncoded,
388    Multipart,
389    Xml,
390    Text,
391    Binary,
392    Unknown,
393}
394#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
395pub enum OpenApiResponseStatus {
396    Exact(u16),
397    Default,
398    Range(u8),
399}
400impl OpenApiResponseStatus {
401    const fn token(self) -> &'static str {
402        match self {
403            Self::Exact(_) => "exact",
404            Self::Default => "default",
405            Self::Range(_) => "range",
406        }
407    }
408    const fn class(self) -> Option<u8> {
409        match self {
410            Self::Exact(value) => Some((value / 100) as u8),
411            Self::Range(class) => Some(class),
412            Self::Default => None,
413        }
414    }
415}
416
417impl OpenApiSchemaPrimitiveKind {
418    const fn token(self) -> &'static str {
419        match self {
420            Self::String => "string",
421            Self::Integer => "integer",
422            Self::Number => "number",
423            Self::Boolean => "boolean",
424            Self::Array => "array",
425            Self::Object => "object",
426            Self::Unknown => "unknown",
427        }
428    }
429}
430
431#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
432pub struct OpenApiOperationId(String);
433
434impl OpenApiOperationId {
435    pub fn as_str(&self) -> &str {
436        &self.0
437    }
438}
439
440impl fmt::Debug for OpenApiOperationId {
441    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
442        formatter.write_str("OpenApiOperationId(")?;
443        formatter.write_str(&self.0)?;
444        formatter.write_str(")")
445    }
446}
447
448#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
449#[cfg_attr(
450    not(all(feature = "ssrf-oast-review", feature = "openapi-review")),
451    derive(Debug)
452)]
453pub struct OpenApiParameterMetadata {
454    location: OpenApiParameterLocation,
455    required: bool,
456    name_digest: [u8; 32],
457    #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
458    execution_name: String,
459    schema_kind: OpenApiSchemaPrimitiveKind,
460    format_class: OpenApiFormatClass,
461}
462
463impl OpenApiParameterMetadata {
464    pub const fn location(&self) -> OpenApiParameterLocation {
465        self.location
466    }
467
468    pub const fn required(&self) -> bool {
469        self.required
470    }
471
472    pub const fn schema_kind(&self) -> OpenApiSchemaPrimitiveKind {
473        self.schema_kind
474    }
475    pub const fn format_class(&self) -> OpenApiFormatClass {
476        self.format_class
477    }
478
479    /// Stable raw-value-free fingerprint of the declared parameter name.
480    pub const fn name_fingerprint(&self) -> &[u8; 32] {
481        &self.name_digest
482    }
483
484    /// Returns the validated clear name only to the in-crate SSRF execution bridge.
485    #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
486    pub(crate) fn execution_name(&self) -> &str {
487        &self.execution_name
488    }
489}
490
491#[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
492impl fmt::Debug for OpenApiParameterMetadata {
493    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
494        formatter
495            .debug_struct("OpenApiParameterMetadata")
496            .field("location", &self.location)
497            .field("required", &self.required)
498            .field("name_digest", &"<stable-digest>")
499            .field("execution_name", &"<redacted>")
500            .field("schema_kind", &self.schema_kind)
501            .field("format_class", &self.format_class)
502            .finish()
503    }
504}
505
506#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
507pub struct OpenApiResponseMetadata {
508    status: OpenApiResponseStatus,
509    media_types: Vec<String>,
510    media_families: Vec<OpenApiMediaFamily>,
511}
512
513impl OpenApiResponseMetadata {
514    pub const fn status(&self) -> OpenApiResponseStatus {
515        self.status
516    }
517    pub fn media_families(&self) -> &[OpenApiMediaFamily] {
518        &self.media_families
519    }
520
521    pub fn media_types(&self) -> &[String] {
522        &self.media_types
523    }
524}
525
526#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
527pub struct OpenApiResponseSummary {
528    success: bool,
529    authentication: bool,
530    client_error: bool,
531    server_error: bool,
532    media_families: Vec<OpenApiMediaFamily>,
533}
534impl OpenApiResponseSummary {
535    pub const fn success(&self) -> bool {
536        self.success
537    }
538    pub const fn authentication(&self) -> bool {
539        self.authentication
540    }
541    pub const fn client_error(&self) -> bool {
542        self.client_error
543    }
544    pub const fn server_error(&self) -> bool {
545        self.server_error
546    }
547    pub fn media_families(&self) -> &[OpenApiMediaFamily] {
548        &self.media_families
549    }
550}
551
552#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
553pub struct OpenApiSecurityMetadata {
554    alternatives: usize,
555    permits_anonymous: bool,
556    declares_auth: bool,
557    scheme_kinds: Vec<OpenApiSecuritySchemeKind>,
558    scope_count: usize,
559}
560
561#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
562pub enum OpenApiSecuritySource {
563    Inherited,
564    OperationOverride,
565    OperationAnonymousOverride,
566}
567impl OpenApiSecuritySource {
568    const fn token(self) -> &'static str {
569        match self {
570            Self::Inherited => "inherited",
571            Self::OperationOverride => "operation-override",
572            Self::OperationAnonymousOverride => "operation-anonymous-override",
573        }
574    }
575}
576
577impl OpenApiSecurityMetadata {
578    pub const fn alternatives(&self) -> usize {
579        self.alternatives
580    }
581
582    pub const fn permits_anonymous(&self) -> bool {
583        self.permits_anonymous
584    }
585    pub const fn declares_auth(&self) -> bool {
586        self.declares_auth
587    }
588
589    pub fn scheme_kinds(&self) -> &[OpenApiSecuritySchemeKind] {
590        &self.scheme_kinds
591    }
592    pub const fn scope_count(&self) -> usize {
593        self.scope_count
594    }
595}
596
597#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
598pub struct OpenApiServerMetadata {
599    kind: OpenApiServerKind,
600    variable_count: usize,
601    canonical_identity: [u8; 32],
602    execution_base: Option<Url>,
603}
604
605impl OpenApiServerMetadata {
606    pub const fn kind(&self) -> OpenApiServerKind {
607        self.kind
608    }
609
610    pub const fn variable_count(&self) -> usize {
611        self.variable_count
612    }
613
614    /// Returns the already-normalized server base only to in-crate execution
615    /// policy. Public metadata deliberately exposes classification, not a URL.
616    #[cfg(any(
617        feature = "rest-review",
618        all(feature = "ssrf-oast-review", feature = "openapi-review")
619    ))]
620    pub(crate) const fn execution_base(&self) -> Option<&Url> {
621        self.execution_base.as_ref()
622    }
623}
624
625impl fmt::Debug for OpenApiServerMetadata {
626    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
627        formatter
628            .debug_struct("OpenApiServerMetadata")
629            .field("kind", &self.kind)
630            .field("variable_count", &self.variable_count)
631            .field("canonical_identity", &"<redacted>")
632            .field("execution_base", &"<redacted>")
633            .finish()
634    }
635}
636
637/// Reduced operation metadata. Debug deliberately omits the raw path.
638#[derive(Clone, Eq, PartialEq)]
639pub struct OpenApiOperation {
640    id: OpenApiOperationId,
641    path: String,
642    method: OpenApiHttpMethod,
643    parameters: Vec<OpenApiParameterMetadata>,
644    request_body_declared: bool,
645    request_media_types: Vec<String>,
646    request_media_families: Vec<OpenApiMediaFamily>,
647    responses: Vec<OpenApiResponseMetadata>,
648    response_summary: OpenApiResponseSummary,
649    security: OpenApiSecurityMetadata,
650    security_source: OpenApiSecuritySource,
651    servers: Vec<OpenApiServerMetadata>,
652    candidate_tags: Vec<OpenApiCandidateTag>,
653    declared_operation_id: Option<[u8; 32]>,
654    deprecated: bool,
655    source_document_identity: String,
656}
657
658impl OpenApiOperation {
659    pub fn id(&self) -> &OpenApiOperationId {
660        &self.id
661    }
662
663    pub fn path(&self) -> &str {
664        &self.path
665    }
666
667    pub const fn method(&self) -> OpenApiHttpMethod {
668        self.method
669    }
670
671    pub fn parameters(&self) -> &[OpenApiParameterMetadata] {
672        &self.parameters
673    }
674
675    /// Returns whether the operation declares a request body, including an
676    /// empty request-body object with no media entries.
677    pub const fn request_body_declared(&self) -> bool {
678        self.request_body_declared
679    }
680
681    pub fn request_media_types(&self) -> &[String] {
682        &self.request_media_types
683    }
684    pub fn request_media_families(&self) -> &[OpenApiMediaFamily] {
685        &self.request_media_families
686    }
687
688    pub fn responses(&self) -> &[OpenApiResponseMetadata] {
689        &self.responses
690    }
691    pub const fn response_summary(&self) -> &OpenApiResponseSummary {
692        &self.response_summary
693    }
694
695    pub const fn security(&self) -> &OpenApiSecurityMetadata {
696        &self.security
697    }
698
699    pub const fn security_source(&self) -> OpenApiSecuritySource {
700        self.security_source
701    }
702
703    pub fn servers(&self) -> &[OpenApiServerMetadata] {
704        &self.servers
705    }
706
707    pub fn candidate_tags(&self) -> &[OpenApiCandidateTag] {
708        &self.candidate_tags
709    }
710    pub const fn has_declared_operation_id(&self) -> bool {
711        self.declared_operation_id.is_some()
712    }
713    /// Stable raw-value-free fingerprint of the declared `operationId`.
714    pub const fn declared_operation_id_fingerprint(&self) -> Option<&[u8; 32]> {
715        self.declared_operation_id.as_ref()
716    }
717    pub const fn deprecated(&self) -> bool {
718        self.deprecated
719    }
720    pub fn source_document_identity(&self) -> &str {
721        &self.source_document_identity
722    }
723}
724
725impl fmt::Debug for OpenApiOperation {
726    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
727        formatter
728            .debug_struct("OpenApiOperation")
729            .field("id", &self.id)
730            .field("method", &self.method)
731            .field("parameter_count", &self.parameters.len())
732            .field("request_body_declared", &self.request_body_declared)
733            .field("request_media_types", &self.request_media_types)
734            .field("responses", &self.responses)
735            .field("security", &self.security)
736            .field("servers", &self.servers)
737            .field("candidate_tags", &self.candidate_tags)
738            .finish()
739    }
740}
741
742#[derive(Clone, Debug, Eq, PartialEq)]
743pub struct OpenApiCatalog {
744    operations: Vec<OpenApiOperation>,
745}
746
747#[derive(Clone, Debug, Eq, PartialEq)]
748pub struct OpenApiCatalogSummary {
749    pub path_parameter_count: u64,
750    pub query_parameter_count: u64,
751    pub url_like_parameter_count: u64,
752    pub multipart_operation_count: u64,
753    pub anonymous_operation_count: u64,
754    pub explicit_auth_operation_count: u64,
755    pub json_compatible_operation_count: u64,
756    pub method_counts: Vec<(OpenApiHttpMethod, u64)>,
757    pub media_family_counts: Vec<(OpenApiMediaFamily, u64)>,
758    pub security_kind_counts: Vec<(OpenApiSecuritySchemeKind, u64)>,
759}
760
761impl OpenApiCatalog {
762    pub fn operations(&self) -> &[OpenApiOperation] {
763        &self.operations
764    }
765
766    pub fn operation(&self, id: &OpenApiOperationId) -> Option<&OpenApiOperation> {
767        self.operations.iter().find(|operation| operation.id == *id)
768    }
769
770    pub fn by_method(&self, method: OpenApiHttpMethod) -> Vec<&OpenApiOperation> {
771        self.operations
772            .iter()
773            .filter(|operation| operation.method == method)
774            .collect()
775    }
776
777    pub fn with_candidate_tag(&self, tag: OpenApiCandidateTag) -> Vec<&OpenApiOperation> {
778        self.operations
779            .iter()
780            .filter(|operation| operation.candidate_tags.binary_search(&tag).is_ok())
781            .collect()
782    }
783
784    pub fn with_any_parameter(&self) -> Vec<&OpenApiOperation> {
785        self.operations
786            .iter()
787            .filter(|operation| !operation.parameters.is_empty())
788            .collect()
789    }
790
791    pub fn with_parameter_location(
792        &self,
793        location: OpenApiParameterLocation,
794    ) -> Vec<&OpenApiOperation> {
795        self.operations
796            .iter()
797            .filter(|operation| {
798                operation
799                    .parameters
800                    .iter()
801                    .any(|parameter| parameter.location == location)
802            })
803            .collect()
804    }
805
806    pub fn with_path_parameter(&self) -> Vec<&OpenApiOperation> {
807        self.with_parameter_location(OpenApiParameterLocation::Path)
808    }
809    pub fn with_query_parameter(&self) -> Vec<&OpenApiOperation> {
810        self.with_parameter_location(OpenApiParameterLocation::Query)
811    }
812    pub fn with_header_parameter(&self) -> Vec<&OpenApiOperation> {
813        self.with_parameter_location(OpenApiParameterLocation::Header)
814    }
815    pub fn with_cookie_parameter(&self) -> Vec<&OpenApiOperation> {
816        self.with_parameter_location(OpenApiParameterLocation::Cookie)
817    }
818    pub fn with_url_like_input(&self) -> Vec<&OpenApiOperation> {
819        self.operations
820            .iter()
821            .filter(|operation| {
822                operation.parameters.iter().any(|parameter| {
823                    matches!(
824                        parameter.format_class,
825                        OpenApiFormatClass::Uri
826                            | OpenApiFormatClass::Url
827                            | OpenApiFormatClass::Hostname
828                    )
829                })
830            })
831            .collect()
832    }
833    pub fn with_multipart_request(&self) -> Vec<&OpenApiOperation> {
834        self.operations
835            .iter()
836            .filter(|operation| {
837                operation
838                    .request_media_families
839                    .contains(&OpenApiMediaFamily::Multipart)
840            })
841            .collect()
842    }
843    pub fn declaring_anonymous_access(&self) -> Vec<&OpenApiOperation> {
844        self.operations
845            .iter()
846            .filter(|operation| operation.security.permits_anonymous)
847            .collect()
848    }
849    pub fn declaring_explicit_auth(&self) -> Vec<&OpenApiOperation> {
850        self.operations
851            .iter()
852            .filter(|operation| operation.security.declares_auth)
853            .collect()
854    }
855    pub fn with_json_compatible_response(&self) -> Vec<&OpenApiOperation> {
856        self.operations
857            .iter()
858            .filter(|operation| {
859                operation
860                    .response_summary
861                    .media_families
862                    .iter()
863                    .any(|family| {
864                        matches!(
865                            family,
866                            OpenApiMediaFamily::Json | OpenApiMediaFamily::JsonSuffix
867                        )
868                    })
869            })
870            .collect()
871    }
872
873    pub fn summary(&self) -> OpenApiCatalogSummary {
874        let mut path = 0u64;
875        let mut query = 0u64;
876        let mut url_like = 0u64;
877        let mut multipart = 0u64;
878        let mut anonymous = 0u64;
879        let mut auth = 0u64;
880        let mut json = 0u64;
881        let mut methods = std::collections::BTreeMap::new();
882        let mut media = std::collections::BTreeMap::new();
883        let mut security = std::collections::BTreeMap::new();
884        for operation in &self.operations {
885            *methods.entry(operation.method).or_insert(0u64) += 1;
886            for parameter in &operation.parameters {
887                match parameter.location {
888                    OpenApiParameterLocation::Path => path += 1,
889                    OpenApiParameterLocation::Query => query += 1,
890                    _ => {},
891                }
892                if matches!(
893                    parameter.format_class,
894                    OpenApiFormatClass::Uri
895                        | OpenApiFormatClass::Url
896                        | OpenApiFormatClass::Hostname
897                ) {
898                    url_like += 1;
899                }
900            }
901            let families = operation
902                .request_media_families
903                .iter()
904                .chain(operation.response_summary.media_families.iter())
905                .copied()
906                .collect::<BTreeSet<_>>();
907            for family in families {
908                *media.entry(family).or_insert(0u64) += 1;
909            }
910            if operation
911                .request_media_families
912                .contains(&OpenApiMediaFamily::Multipart)
913            {
914                multipart += 1;
915            }
916            anonymous += u64::from(operation.security.permits_anonymous);
917            auth += u64::from(operation.security.declares_auth);
918            if operation
919                .response_summary
920                .media_families
921                .iter()
922                .any(|family| {
923                    matches!(
924                        family,
925                        OpenApiMediaFamily::Json | OpenApiMediaFamily::JsonSuffix
926                    )
927                })
928            {
929                json += 1;
930            }
931            for kind in &operation.security.scheme_kinds {
932                *security.entry(*kind).or_insert(0u64) += 1;
933            }
934        }
935        OpenApiCatalogSummary {
936            path_parameter_count: path,
937            query_parameter_count: query,
938            url_like_parameter_count: url_like,
939            multipart_operation_count: multipart,
940            anonymous_operation_count: anonymous,
941            explicit_auth_operation_count: auth,
942            json_compatible_operation_count: json,
943            method_counts: methods.into_iter().collect(),
944            media_family_counts: media.into_iter().collect(),
945            security_kind_counts: security.into_iter().collect(),
946        }
947    }
948}
949
950#[derive(Clone, Debug, Eq, PartialEq)]
951pub struct OpenApiDocument {
952    kind: OpenApiDocumentKind,
953    path_count: usize,
954    digest: String,
955    servers: Vec<OpenApiServerMetadata>,
956    security_schemes: Vec<OpenApiSecuritySchemeKind>,
957    catalog: OpenApiCatalog,
958    title_present: bool,
959    root_security: OpenApiSecurityMetadata,
960    root_security_declared: bool,
961}
962
963impl OpenApiDocument {
964    pub fn parse_json(body: &[u8]) -> Result<Self, OpenApiReviewError> {
965        Self::parse_json_with_limits(body, OpenApiDocumentLimits::default())
966    }
967
968    pub fn parse_json_with_limits(
969        body: &[u8],
970        limits: OpenApiDocumentLimits,
971    ) -> Result<Self, OpenApiReviewError> {
972        let origin = Url::parse("https://example.invalid/").expect("compiled URL is valid");
973        parse_json_at_origin(body, limits, &origin)
974    }
975
976    pub const fn kind(&self) -> OpenApiDocumentKind {
977        self.kind
978    }
979
980    pub const fn version(&self) -> Option<OpenApiVersion> {
981        match self.kind {
982            OpenApiDocumentKind::OpenApi30 => Some(OpenApiVersion::OpenApi30),
983            OpenApiDocumentKind::OpenApi31 => Some(OpenApiVersion::OpenApi31),
984            OpenApiDocumentKind::Swagger20MetadataOnly => None,
985        }
986    }
987
988    pub const fn path_count(&self) -> usize {
989        self.path_count
990    }
991
992    pub fn operation_count(&self) -> usize {
993        self.catalog.operations.len()
994    }
995
996    pub fn semantic_digest(&self) -> &str {
997        &self.digest
998    }
999
1000    pub fn digest(&self) -> &str {
1001        &self.digest
1002    }
1003
1004    pub fn servers(&self) -> &[OpenApiServerMetadata] {
1005        &self.servers
1006    }
1007
1008    pub fn security_schemes(&self) -> &[OpenApiSecuritySchemeKind] {
1009        &self.security_schemes
1010    }
1011
1012    pub const fn catalog(&self) -> &OpenApiCatalog {
1013        &self.catalog
1014    }
1015    pub const fn title_present(&self) -> bool {
1016        self.title_present
1017    }
1018    pub const fn root_security(&self) -> &OpenApiSecurityMetadata {
1019        &self.root_security
1020    }
1021    pub const fn root_security_declared(&self) -> bool {
1022        self.root_security_declared
1023    }
1024}
1025
1026#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
1027pub enum OpenApiReviewError {
1028    #[error("the OpenAPI parser limits are invalid")]
1029    InvalidLimits,
1030    #[error("the OpenAPI document size is outside the allowed range")]
1031    DocumentSize,
1032    #[error("the OpenAPI document is malformed JSON")]
1033    MalformedJson,
1034    #[error("the OpenAPI document JSON exceeds a structural limit")]
1035    JsonLimit,
1036    #[error("the document is not a supported OpenAPI JSON shape")]
1037    UnsupportedDocument,
1038    #[error("the OpenAPI version is unsupported")]
1039    UnsupportedVersion,
1040    #[error("an external reference is unsupported")]
1041    ExternalReference,
1042    #[error("the OpenAPI paths catalog is invalid")]
1043    InvalidPaths,
1044    #[error("the OpenAPI operation metadata is invalid")]
1045    InvalidOperation,
1046    #[error("the OpenAPI catalog exceeds a compiled limit")]
1047    CatalogLimit,
1048}
1049
1050fn parse_document(
1051    root: Value,
1052    limits: OpenApiDocumentLimits,
1053    origin: &Url,
1054) -> Result<OpenApiDocument, OpenApiReviewError> {
1055    let object = root
1056        .as_object()
1057        .ok_or(OpenApiReviewError::UnsupportedDocument)?;
1058    let kind = classify_document(object)?;
1059    if !kind.executable_catalog() {
1060        let catalog = OpenApiCatalog {
1061            operations: Vec::new(),
1062        };
1063        let servers = Vec::new();
1064        let security_schemes = Vec::new();
1065        let root_security = OpenApiSecurityMetadata {
1066            alternatives: 0,
1067            permits_anonymous: true,
1068            declares_auth: false,
1069            scheme_kinds: Vec::new(),
1070            scope_count: 0,
1071        };
1072        let digest = document_digest(
1073            kind,
1074            false,
1075            0,
1076            &root_security,
1077            &servers,
1078            &security_schemes,
1079            &catalog,
1080        );
1081        return Ok(OpenApiDocument {
1082            kind,
1083            path_count: 0,
1084            digest,
1085            servers,
1086            security_schemes,
1087            catalog,
1088            title_present: false,
1089            root_security,
1090            root_security_declared: false,
1091        });
1092    }
1093
1094    let servers = parse_servers(object.get("servers"), origin)?;
1095    let security_scheme_map = parse_security_schemes(object)?;
1096    let mut security_schemes = security_scheme_map
1097        .iter()
1098        .map(|(_, kind)| *kind)
1099        .collect::<Vec<_>>();
1100    security_schemes.sort_unstable();
1101    security_schemes.dedup();
1102    let inherited_security = parse_security(object.get("security"), &security_scheme_map)?;
1103    let root_security_declared = object.contains_key("security");
1104    let title_present = object
1105        .get("info")
1106        .and_then(Value::as_object)
1107        .and_then(|info| info.get("title"))
1108        .and_then(Value::as_str)
1109        .is_some_and(|title| !title.is_empty());
1110    let paths = object
1111        .get("paths")
1112        .and_then(Value::as_object)
1113        .ok_or(OpenApiReviewError::InvalidPaths)?;
1114    let path_count = paths.keys().filter(|path| !path.starts_with("x-")).count();
1115    if path_count > limits.paths {
1116        return Err(OpenApiReviewError::CatalogLimit);
1117    }
1118    let mut operations = Vec::new();
1119    for (path, path_item) in paths {
1120        if path.starts_with("x-") {
1121            continue;
1122        }
1123        validate_path(path)?;
1124        let path_item = path_item
1125            .as_object()
1126            .ok_or(OpenApiReviewError::InvalidPaths)?;
1127        let inherited_parameters = parse_parameters(path_item.get("parameters"), &root)?;
1128        let path_servers_declared = path_item.contains_key("servers");
1129        let path_servers = parse_servers(path_item.get("servers"), origin)?;
1130        for method in OpenApiHttpMethod::ALL {
1131            let Some(operation) = path_item.get(method.token()) else {
1132                continue;
1133            };
1134            let operation = operation
1135                .as_object()
1136                .ok_or(OpenApiReviewError::InvalidOperation)?;
1137            let operation_parameters = parse_parameters(operation.get("parameters"), &root)?;
1138            let mut parameters = inherited_parameters.clone();
1139            for replacement in operation_parameters {
1140                parameters.retain(|existing| {
1141                    existing.location != replacement.location
1142                        || existing.name_digest != replacement.name_digest
1143                });
1144                parameters.push(replacement);
1145            }
1146            parameters.sort_unstable();
1147            parameters.dedup();
1148            if parameters.len() > MAX_OPENAPI_PARAMETERS_PER_OPERATION {
1149                return Err(OpenApiReviewError::CatalogLimit);
1150            }
1151            let placeholders = path_placeholder_digests(path)?;
1152            let declared_path_parameters = parameters
1153                .iter()
1154                .filter(|parameter| parameter.location == OpenApiParameterLocation::Path)
1155                .map(|parameter| parameter.name_digest)
1156                .collect::<BTreeSet<_>>();
1157            if placeholders != declared_path_parameters {
1158                return Err(OpenApiReviewError::InvalidOperation);
1159            }
1160            let request_body_declared = operation.contains_key("requestBody");
1161            let request_media_types = parse_request_media(operation.get("requestBody"))?;
1162            let request_media_families = media_families(&request_media_types);
1163            let responses = parse_responses(operation.get("responses"))?;
1164            let media_entry_count = responses
1165                .iter()
1166                .try_fold(request_media_types.len(), |count, response| {
1167                    count.checked_add(response.media_types.len())
1168                })
1169                .ok_or(OpenApiReviewError::CatalogLimit)?;
1170            if media_entry_count > MAX_MEDIA_ENTRIES_PER_OPERATION {
1171                return Err(OpenApiReviewError::CatalogLimit);
1172            }
1173            let response_summary = summarize_responses(&responses);
1174            let (security, security_source) = if operation.contains_key("security") {
1175                let security = parse_security(operation.get("security"), &security_scheme_map)?;
1176                let source = if security.permits_anonymous && security.alternatives == 0 {
1177                    OpenApiSecuritySource::OperationAnonymousOverride
1178                } else {
1179                    OpenApiSecuritySource::OperationOverride
1180                };
1181                (security, source)
1182            } else {
1183                (inherited_security.clone(), OpenApiSecuritySource::Inherited)
1184            };
1185            let servers = if operation.contains_key("servers") {
1186                parse_servers(operation.get("servers"), origin)?
1187            } else if path_servers_declared {
1188                path_servers.clone()
1189            } else {
1190                servers.clone()
1191            };
1192            let deprecated = operation
1193                .get("deprecated")
1194                .map(|value| value.as_bool().ok_or(OpenApiReviewError::InvalidOperation))
1195                .transpose()?
1196                .unwrap_or(false);
1197            let declared_operation_id = operation
1198                .get("operationId")
1199                .map(|value| bounded_token(Some(value)))
1200                .transpose()?
1201                .map(|value| digest_bytes(b"openapi-declared-operation-id/v1", value.as_bytes()));
1202            let candidate_tags = candidate_tags(
1203                method,
1204                &parameters,
1205                request_body_declared,
1206                &request_media_types,
1207                &responses,
1208                &security,
1209                deprecated,
1210            );
1211            let id = operation_id(OperationIdentityInput {
1212                path,
1213                method,
1214                parameters: &parameters,
1215                request_body_declared,
1216                request_media: &request_media_types,
1217                responses: &responses,
1218                security: &security,
1219                security_source,
1220                servers: &servers,
1221                tags: &candidate_tags,
1222                declared_operation_id: declared_operation_id.as_ref(),
1223                deprecated,
1224            });
1225            operations.push(OpenApiOperation {
1226                id,
1227                path: path.clone(),
1228                method,
1229                parameters,
1230                request_body_declared,
1231                request_media_types,
1232                request_media_families,
1233                responses,
1234                response_summary,
1235                security,
1236                security_source,
1237                servers,
1238                candidate_tags,
1239                declared_operation_id,
1240                deprecated,
1241                source_document_identity: String::new(),
1242            });
1243            if operations.len() > limits.operations {
1244                return Err(OpenApiReviewError::CatalogLimit);
1245            }
1246        }
1247    }
1248    operations.sort_by(|left, right| {
1249        left.path
1250            .cmp(&right.path)
1251            .then(left.method.cmp(&right.method))
1252    });
1253    let mut catalog = OpenApiCatalog { operations };
1254    let digest = document_digest(
1255        kind,
1256        root_security_declared,
1257        path_count,
1258        &inherited_security,
1259        &servers,
1260        &security_schemes,
1261        &catalog,
1262    );
1263    for operation in &mut catalog.operations {
1264        operation.source_document_identity = digest.clone();
1265        operation.id = scoped_operation_id(&digest, &operation.id);
1266    }
1267    Ok(OpenApiDocument {
1268        kind,
1269        path_count,
1270        digest,
1271        servers,
1272        security_schemes,
1273        catalog,
1274        title_present,
1275        root_security: inherited_security,
1276        root_security_declared,
1277    })
1278}
1279
1280struct UniqueValue(Value);
1281impl<'de> Deserialize<'de> for UniqueValue {
1282    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1283        struct UniqueVisitor;
1284        impl<'de> Visitor<'de> for UniqueVisitor {
1285            type Value = UniqueValue;
1286            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1287                formatter.write_str("a JSON value without duplicate object keys")
1288            }
1289            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
1290                Ok(UniqueValue(Value::Bool(value)))
1291            }
1292            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
1293                Ok(UniqueValue(Value::Number(value.into())))
1294            }
1295            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
1296                Ok(UniqueValue(Value::Number(value.into())))
1297            }
1298            fn visit_f64<E: serde::de::Error>(self, value: f64) -> Result<Self::Value, E> {
1299                serde_json::Number::from_f64(value)
1300                    .map(Value::Number)
1301                    .map(UniqueValue)
1302                    .ok_or_else(|| E::custom("non-finite number"))
1303            }
1304            fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
1305                Ok(UniqueValue(Value::String(value.to_owned())))
1306            }
1307            fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
1308                Ok(UniqueValue(Value::String(value)))
1309            }
1310            fn visit_none<E>(self) -> Result<Self::Value, E> {
1311                Ok(UniqueValue(Value::Null))
1312            }
1313            fn visit_unit<E>(self) -> Result<Self::Value, E> {
1314                Ok(UniqueValue(Value::Null))
1315            }
1316            fn visit_some<D: Deserializer<'de>>(
1317                self,
1318                deserializer: D,
1319            ) -> Result<Self::Value, D::Error> {
1320                Deserialize::deserialize(deserializer)
1321            }
1322            fn visit_seq<A: SeqAccess<'de>>(
1323                self,
1324                mut sequence: A,
1325            ) -> Result<Self::Value, A::Error> {
1326                let mut values = Vec::new();
1327                while let Some(value) = sequence.next_element::<UniqueValue>()? {
1328                    values.push(value.0);
1329                }
1330                Ok(UniqueValue(Value::Array(values)))
1331            }
1332            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
1333                let mut values = Map::new();
1334                while let Some((key, value)) = map.next_entry::<String, UniqueValue>()? {
1335                    if values.insert(key, value.0).is_some() {
1336                        return Err(serde::de::Error::custom("duplicate object key"));
1337                    }
1338                }
1339                Ok(UniqueValue(Value::Object(values)))
1340            }
1341        }
1342        deserializer.deserialize_any(UniqueVisitor)
1343    }
1344}
1345
1346fn classify_document(
1347    object: &Map<String, Value>,
1348) -> Result<OpenApiDocumentKind, OpenApiReviewError> {
1349    if object.contains_key("openapi") && object.contains_key("swagger") {
1350        return Err(OpenApiReviewError::UnsupportedDocument);
1351    }
1352    if let Some(version) = object.get("openapi").and_then(Value::as_str) {
1353        let valid_patch = |prefix: &str| {
1354            version.strip_prefix(prefix).is_some_and(|patch| {
1355                !patch.is_empty() && patch.bytes().all(|byte| byte.is_ascii_digit())
1356            })
1357        };
1358        return if valid_patch("3.0.") {
1359            Ok(OpenApiDocumentKind::OpenApi30)
1360        } else if valid_patch("3.1.") {
1361            Ok(OpenApiDocumentKind::OpenApi31)
1362        } else {
1363            Err(OpenApiReviewError::UnsupportedVersion)
1364        };
1365    }
1366    if object.get("swagger").and_then(Value::as_str) == Some("2.0") {
1367        return Ok(OpenApiDocumentKind::Swagger20MetadataOnly);
1368    }
1369    Err(OpenApiReviewError::UnsupportedDocument)
1370}
1371
1372#[derive(Default)]
1373struct JsonCount {
1374    nodes: usize,
1375    members: usize,
1376}
1377
1378fn validate_json_limits(
1379    value: &Value,
1380    limits: OpenApiDocumentLimits,
1381) -> Result<(), OpenApiReviewError> {
1382    let mut count = JsonCount::default();
1383    validate_json_value(value, 1, limits, &mut count)
1384}
1385
1386fn validate_json_value(
1387    value: &Value,
1388    depth: usize,
1389    limits: OpenApiDocumentLimits,
1390    count: &mut JsonCount,
1391) -> Result<(), OpenApiReviewError> {
1392    count.nodes = count
1393        .nodes
1394        .checked_add(1)
1395        .ok_or(OpenApiReviewError::JsonLimit)?;
1396    if depth > limits.depth || count.nodes > limits.nodes {
1397        return Err(OpenApiReviewError::JsonLimit);
1398    }
1399    match value {
1400        Value::String(value) if value.len() > limits.string_bytes => {
1401            Err(OpenApiReviewError::JsonLimit)
1402        },
1403        Value::Array(values) => {
1404            if values.len() > limits.array_length {
1405                return Err(OpenApiReviewError::JsonLimit);
1406            }
1407            for value in values {
1408                validate_json_value(value, depth + 1, limits, count)?;
1409            }
1410            Ok(())
1411        },
1412        Value::Object(values) => {
1413            count.members = count
1414                .members
1415                .checked_add(values.len())
1416                .ok_or(OpenApiReviewError::JsonLimit)?;
1417            if values.len() > limits.object_members || count.members > limits.object_members {
1418                return Err(OpenApiReviewError::JsonLimit);
1419            }
1420            for (key, value) in values {
1421                if key.len() > limits.string_bytes {
1422                    return Err(OpenApiReviewError::JsonLimit);
1423                }
1424                validate_json_value(value, depth + 1, limits, count)?;
1425            }
1426            Ok(())
1427        },
1428        _ => Ok(()),
1429    }
1430}
1431
1432fn validate_references(value: &Value) -> Result<(), OpenApiReviewError> {
1433    match value {
1434        Value::Array(values) => values.iter().try_for_each(validate_references),
1435        Value::Object(values) => {
1436            if values.get("$ref").is_some_and(|value| !value.is_string()) {
1437                return Err(OpenApiReviewError::ExternalReference);
1438            }
1439            if let Some(reference) = values.get("$ref").and_then(Value::as_str) {
1440                if !reference.starts_with("#/") {
1441                    return Err(OpenApiReviewError::ExternalReference);
1442                }
1443            }
1444            values.values().try_for_each(validate_references)
1445        },
1446        _ => Ok(()),
1447    }
1448}
1449
1450fn validate_path(path: &str) -> Result<(), OpenApiReviewError> {
1451    if path.is_empty()
1452        || path.len() > MAX_OPENAPI_PATH_BYTES
1453        || !path.starts_with('/')
1454        || path.contains(['?', '#', '\r', '\n', '\0'])
1455        || path
1456            .split('/')
1457            .any(|segment| segment == "." || segment == "..")
1458        || path.to_ascii_lowercase().contains("%2f")
1459        || path.to_ascii_lowercase().contains("%5c")
1460        || path.to_ascii_lowercase().contains("%2e")
1461        || path.contains('\\')
1462        || path.contains("//")
1463        || path.split('/').count().saturating_sub(1) > MAX_OPENAPI_PATH_SEGMENTS
1464    {
1465        return Err(OpenApiReviewError::InvalidPaths);
1466    }
1467    let mut placeholders = BTreeSet::new();
1468    for name in braced_tokens(path).map_err(|()| OpenApiReviewError::InvalidPaths)? {
1469        if name.is_empty()
1470            || name.len() > MAX_OPENAPI_TOKEN_BYTES
1471            || !name.as_bytes()[0].is_ascii_alphabetic() && name.as_bytes()[0] != b'_'
1472            || !name
1473                .bytes()
1474                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
1475            || !placeholders.insert(name)
1476        {
1477            return Err(OpenApiReviewError::InvalidPaths);
1478        }
1479    }
1480    Ok(())
1481}
1482
1483fn path_placeholder_digests(path: &str) -> Result<BTreeSet<[u8; 32]>, OpenApiReviewError> {
1484    let mut placeholders = BTreeSet::new();
1485    for name in braced_tokens(path).map_err(|()| OpenApiReviewError::InvalidPaths)? {
1486        placeholders.insert(digest_bytes(b"openapi-parameter-name/v1", name.as_bytes()));
1487    }
1488    Ok(placeholders)
1489}
1490
1491fn parse_parameters(
1492    value: Option<&Value>,
1493    document: &Value,
1494) -> Result<Vec<OpenApiParameterMetadata>, OpenApiReviewError> {
1495    let Some(value) = value else {
1496        return Ok(Vec::new());
1497    };
1498    let values = value
1499        .as_array()
1500        .ok_or(OpenApiReviewError::InvalidOperation)?;
1501    if values.len() > MAX_OPENAPI_PARAMETERS_PER_OPERATION {
1502        return Err(OpenApiReviewError::CatalogLimit);
1503    }
1504    let mut parameters = Vec::with_capacity(values.len());
1505    let mut identities = BTreeSet::new();
1506    for value in values {
1507        let mut object = value
1508            .as_object()
1509            .ok_or(OpenApiReviewError::InvalidOperation)?;
1510        if let Some(reference) = object.get("$ref") {
1511            let reference = reference
1512                .as_str()
1513                .ok_or(OpenApiReviewError::InvalidOperation)?;
1514            if !reference.starts_with("#/components/parameters/") {
1515                return Err(OpenApiReviewError::InvalidOperation);
1516            }
1517            object = document
1518                .pointer(&reference[1..])
1519                .and_then(Value::as_object)
1520                .ok_or(OpenApiReviewError::InvalidOperation)?;
1521            if object.contains_key("$ref") {
1522                return Err(OpenApiReviewError::InvalidOperation);
1523            }
1524        }
1525        let name = bounded_token(object.get("name"))?;
1526        let location = object
1527            .get("in")
1528            .and_then(Value::as_str)
1529            .and_then(OpenApiParameterLocation::parse)
1530            .ok_or(OpenApiReviewError::InvalidOperation)?;
1531        let required = object
1532            .get("required")
1533            .map(|value| value.as_bool().ok_or(OpenApiReviewError::InvalidOperation))
1534            .transpose()?
1535            .unwrap_or(false);
1536        if location == OpenApiParameterLocation::Path && !required {
1537            return Err(OpenApiReviewError::InvalidOperation);
1538        }
1539        let schema = object
1540            .get("schema")
1541            .map(|value| {
1542                value
1543                    .as_object()
1544                    .ok_or(OpenApiReviewError::InvalidOperation)
1545            })
1546            .transpose()?;
1547        if schema
1548            .and_then(|value| value.get("type"))
1549            .is_some_and(|value| !value.is_string())
1550            || schema
1551                .and_then(|value| value.get("format"))
1552                .is_some_and(|value| !value.is_string())
1553        {
1554            return Err(OpenApiReviewError::InvalidOperation);
1555        }
1556        let schema_kind = match schema
1557            .and_then(|value| value.get("type"))
1558            .and_then(Value::as_str)
1559        {
1560            Some("string") => OpenApiSchemaPrimitiveKind::String,
1561            Some("integer") => OpenApiSchemaPrimitiveKind::Integer,
1562            Some("number") => OpenApiSchemaPrimitiveKind::Number,
1563            Some("boolean") => OpenApiSchemaPrimitiveKind::Boolean,
1564            Some("array") => OpenApiSchemaPrimitiveKind::Array,
1565            Some("object") => OpenApiSchemaPrimitiveKind::Object,
1566            _ => OpenApiSchemaPrimitiveKind::Unknown,
1567        };
1568        let format_class = match schema
1569            .and_then(|value| value.get("format"))
1570            .and_then(Value::as_str)
1571        {
1572            Some("uri") => OpenApiFormatClass::Uri,
1573            Some("url") => OpenApiFormatClass::Url,
1574            Some("hostname") => OpenApiFormatClass::Hostname,
1575            Some("uuid") => OpenApiFormatClass::Uuid,
1576            Some("email") => OpenApiFormatClass::Email,
1577            Some("password") => OpenApiFormatClass::Password,
1578            Some("binary") => OpenApiFormatClass::Binary,
1579            Some("byte") => OpenApiFormatClass::Byte,
1580            Some("date-time") => OpenApiFormatClass::DateTime,
1581            Some("date") => OpenApiFormatClass::Date,
1582            Some(_) => OpenApiFormatClass::Other,
1583            None => OpenApiFormatClass::None,
1584        };
1585        let metadata = OpenApiParameterMetadata {
1586            location,
1587            required,
1588            name_digest: digest_bytes(b"openapi-parameter-name/v1", name.as_bytes()),
1589            #[cfg(all(feature = "ssrf-oast-review", feature = "openapi-review"))]
1590            execution_name: name.to_owned(),
1591            schema_kind,
1592            format_class,
1593        };
1594        if !identities.insert((metadata.location, metadata.name_digest)) {
1595            return Err(OpenApiReviewError::InvalidOperation);
1596        }
1597        parameters.push(metadata);
1598    }
1599    Ok(parameters)
1600}
1601
1602fn parse_request_media(value: Option<&Value>) -> Result<Vec<String>, OpenApiReviewError> {
1603    let Some(value) = value else {
1604        return Ok(Vec::new());
1605    };
1606    let object = value
1607        .as_object()
1608        .ok_or(OpenApiReviewError::InvalidOperation)?;
1609    parse_media_map(object.get("content"))
1610}
1611
1612fn parse_media_map(value: Option<&Value>) -> Result<Vec<String>, OpenApiReviewError> {
1613    let Some(value) = value else {
1614        return Ok(Vec::new());
1615    };
1616    let object = value
1617        .as_object()
1618        .ok_or(OpenApiReviewError::InvalidOperation)?;
1619    if object.len() > MAX_OPENAPI_MEDIA_TYPES_PER_OPERATION {
1620        return Err(OpenApiReviewError::CatalogLimit);
1621    }
1622    if object.values().any(|value| !value.is_object()) {
1623        return Err(OpenApiReviewError::InvalidOperation);
1624    }
1625    let mut media = object
1626        .keys()
1627        .map(|value| normalize_media_type(value))
1628        .collect::<Result<Vec<_>, _>>()?;
1629    media.sort();
1630    media.dedup();
1631    Ok(media)
1632}
1633
1634fn normalize_media_type(value: &str) -> Result<String, OpenApiReviewError> {
1635    if value.is_empty() || value.len() > MAX_OPENAPI_TOKEN_BYTES || !value.is_ascii() {
1636        return Err(OpenApiReviewError::InvalidOperation);
1637    }
1638    let essence = value
1639        .split(';')
1640        .next()
1641        .unwrap_or_default()
1642        .trim()
1643        .to_ascii_lowercase();
1644    if essence.matches('/').count() != 1 {
1645        return Err(OpenApiReviewError::InvalidOperation);
1646    }
1647    let Some((kind, subtype)) = essence.split_once('/') else {
1648        return Err(OpenApiReviewError::InvalidOperation);
1649    };
1650    if kind.is_empty()
1651        || subtype.is_empty()
1652        || !essence.bytes().all(|byte| {
1653            byte.is_ascii_alphanumeric()
1654                || matches!(
1655                    byte,
1656                    b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'*' | b'/'
1657                )
1658        })
1659    {
1660        return Err(OpenApiReviewError::InvalidOperation);
1661    }
1662    Ok(essence)
1663}
1664
1665fn media_family(essence: &str) -> OpenApiMediaFamily {
1666    if essence == "application/json" {
1667        OpenApiMediaFamily::Json
1668    } else if essence.ends_with("+json") {
1669        OpenApiMediaFamily::JsonSuffix
1670    } else if essence == "application/x-www-form-urlencoded" {
1671        OpenApiMediaFamily::FormUrlEncoded
1672    } else if essence.starts_with("multipart/") {
1673        OpenApiMediaFamily::Multipart
1674    } else if essence == "application/xml" || essence == "text/xml" || essence.ends_with("+xml") {
1675        OpenApiMediaFamily::Xml
1676    } else if essence.starts_with("text/") {
1677        OpenApiMediaFamily::Text
1678    } else if essence == "application/octet-stream"
1679        || essence.starts_with("image/")
1680        || essence.starts_with("audio/")
1681        || essence.starts_with("video/")
1682        || essence == "application/pdf"
1683    {
1684        OpenApiMediaFamily::Binary
1685    } else {
1686        OpenApiMediaFamily::Unknown
1687    }
1688}
1689
1690fn media_families(media: &[String]) -> Vec<OpenApiMediaFamily> {
1691    media
1692        .iter()
1693        .map(|value| media_family(value))
1694        .collect::<BTreeSet<_>>()
1695        .into_iter()
1696        .collect()
1697}
1698
1699fn parse_responses(
1700    value: Option<&Value>,
1701) -> Result<Vec<OpenApiResponseMetadata>, OpenApiReviewError> {
1702    let Some(value) = value else {
1703        return Ok(Vec::new());
1704    };
1705    let object = value
1706        .as_object()
1707        .ok_or(OpenApiReviewError::InvalidOperation)?;
1708    if object.len() > MAX_OPENAPI_RESPONSES_PER_OPERATION {
1709        return Err(OpenApiReviewError::CatalogLimit);
1710    }
1711    let mut responses = Vec::with_capacity(object.len());
1712    for (status, response) in object {
1713        let status = if status == "default" {
1714            OpenApiResponseStatus::Default
1715        } else if status.len() == 3
1716            && status.as_bytes()[1..] == *b"XX"
1717            && matches!(status.as_bytes()[0], b'1'..=b'5')
1718        {
1719            OpenApiResponseStatus::Range(status.as_bytes()[0] - b'0')
1720        } else if status.len() == 3 && status.bytes().all(|byte| byte.is_ascii_digit()) {
1721            let code = status
1722                .parse::<u16>()
1723                .map_err(|_| OpenApiReviewError::InvalidOperation)?;
1724            if !(100..=599).contains(&code) {
1725                return Err(OpenApiReviewError::InvalidOperation);
1726            }
1727            OpenApiResponseStatus::Exact(code)
1728        } else {
1729            return Err(OpenApiReviewError::InvalidOperation);
1730        };
1731        let response_object = response
1732            .as_object()
1733            .ok_or(OpenApiReviewError::InvalidOperation)?;
1734        let media_types = parse_media_map(response_object.get("content"))?;
1735        responses.push(OpenApiResponseMetadata {
1736            status,
1737            media_families: media_families(&media_types),
1738            media_types,
1739        });
1740    }
1741    responses.sort_unstable();
1742    Ok(responses)
1743}
1744
1745fn summarize_responses(responses: &[OpenApiResponseMetadata]) -> OpenApiResponseSummary {
1746    let mut families = BTreeSet::new();
1747    let mut success = false;
1748    let mut authentication = false;
1749    let mut client_error = false;
1750    let mut server_error = false;
1751    for response in responses {
1752        families.extend(response.media_families.iter().copied());
1753        match response.status {
1754            OpenApiResponseStatus::Exact(401 | 403) => {
1755                authentication = true;
1756                client_error = true;
1757            },
1758            status => match status.class() {
1759                Some(2) => success = true,
1760                Some(4) => client_error = true,
1761                Some(5) => server_error = true,
1762                _ => {},
1763            },
1764        }
1765    }
1766    OpenApiResponseSummary {
1767        success,
1768        authentication,
1769        client_error,
1770        server_error,
1771        media_families: families.into_iter().collect(),
1772    }
1773}
1774
1775fn parse_security_schemes(
1776    object: &Map<String, Value>,
1777) -> Result<Vec<(String, OpenApiSecuritySchemeKind)>, OpenApiReviewError> {
1778    let Some(components) = object.get("components") else {
1779        return Ok(Vec::new());
1780    };
1781    let components = components
1782        .as_object()
1783        .ok_or(OpenApiReviewError::InvalidOperation)?;
1784    let Some(schemes_value) = components.get("securitySchemes") else {
1785        return Ok(Vec::new());
1786    };
1787    let schemes = schemes_value
1788        .as_object()
1789        .ok_or(OpenApiReviewError::InvalidOperation)?;
1790    if schemes.len() > MAX_OPENAPI_SECURITY_REQUIREMENTS {
1791        return Err(OpenApiReviewError::CatalogLimit);
1792    }
1793    let mut result = Vec::with_capacity(schemes.len());
1794    for (name, value) in schemes {
1795        if name.is_empty() || name.len() > MAX_OPENAPI_TOKEN_BYTES {
1796            return Err(OpenApiReviewError::InvalidOperation);
1797        }
1798        let object = value
1799            .as_object()
1800            .ok_or(OpenApiReviewError::InvalidOperation)?;
1801        if object.get("type").is_some_and(|value| !value.is_string())
1802            || object.get("in").is_some_and(|value| !value.is_string())
1803            || object.get("scheme").is_some_and(|value| !value.is_string())
1804        {
1805            return Err(OpenApiReviewError::InvalidOperation);
1806        }
1807        let kind = match object.get("type").and_then(Value::as_str) {
1808            Some("apiKey") => match object.get("in").and_then(Value::as_str) {
1809                Some("query") => OpenApiSecuritySchemeKind::ApiKeyQuery,
1810                Some("header") => OpenApiSecuritySchemeKind::ApiKeyHeader,
1811                Some("cookie") => OpenApiSecuritySchemeKind::ApiKeyCookie,
1812                _ => OpenApiSecuritySchemeKind::Unknown,
1813            },
1814            Some("http") => match object
1815                .get("scheme")
1816                .and_then(Value::as_str)
1817                .map(|value| value.to_ascii_lowercase())
1818                .as_deref()
1819            {
1820                Some("basic") => OpenApiSecuritySchemeKind::HttpBasic,
1821                Some("bearer") => OpenApiSecuritySchemeKind::HttpBearer,
1822                _ => OpenApiSecuritySchemeKind::HttpOther,
1823            },
1824            Some("mutualTLS") => OpenApiSecuritySchemeKind::MutualTls,
1825            Some("oauth2") => OpenApiSecuritySchemeKind::OAuth2,
1826            Some("openIdConnect") => OpenApiSecuritySchemeKind::OpenIdConnect,
1827            _ => OpenApiSecuritySchemeKind::Unknown,
1828        };
1829        result.push((name.clone(), kind));
1830    }
1831    result.sort_by(|left, right| left.0.cmp(&right.0));
1832    Ok(result)
1833}
1834
1835fn parse_security(
1836    value: Option<&Value>,
1837    schemes: &[(String, OpenApiSecuritySchemeKind)],
1838) -> Result<OpenApiSecurityMetadata, OpenApiReviewError> {
1839    let Some(value) = value else {
1840        return Ok(OpenApiSecurityMetadata {
1841            alternatives: 0,
1842            permits_anonymous: true,
1843            declares_auth: false,
1844            scheme_kinds: Vec::new(),
1845            scope_count: 0,
1846        });
1847    };
1848    let requirements = value
1849        .as_array()
1850        .ok_or(OpenApiReviewError::InvalidOperation)?;
1851    if requirements.len() > MAX_OPENAPI_SECURITY_REQUIREMENTS {
1852        return Err(OpenApiReviewError::CatalogLimit);
1853    }
1854    let mut kinds = BTreeSet::new();
1855    let mut permits_anonymous = requirements.is_empty();
1856    let mut declares_auth = false;
1857    let mut scheme_count = 0usize;
1858    let mut scope_count = 0usize;
1859    for requirement in requirements {
1860        let requirement = requirement
1861            .as_object()
1862            .ok_or(OpenApiReviewError::InvalidOperation)?;
1863        permits_anonymous |= requirement.is_empty();
1864        declares_auth |= !requirement.is_empty();
1865        for (name, scopes) in requirement {
1866            scheme_count = scheme_count
1867                .checked_add(1)
1868                .ok_or(OpenApiReviewError::CatalogLimit)?;
1869            if scheme_count > MAX_OPENAPI_SECURITY_REQUIREMENTS {
1870                return Err(OpenApiReviewError::CatalogLimit);
1871            }
1872            kinds.insert(
1873                schemes
1874                    .iter()
1875                    .find(|(candidate, _)| candidate == name)
1876                    .map_or(OpenApiSecuritySchemeKind::Unknown, |(_, kind)| *kind),
1877            );
1878            let scopes = scopes
1879                .as_array()
1880                .ok_or(OpenApiReviewError::InvalidOperation)?;
1881            if scopes.iter().any(|scope| !scope.is_string()) {
1882                return Err(OpenApiReviewError::InvalidOperation);
1883            }
1884            scope_count = scope_count
1885                .checked_add(scopes.len())
1886                .ok_or(OpenApiReviewError::CatalogLimit)?;
1887            if scope_count > MAX_OPENAPI_SECURITY_REQUIREMENTS {
1888                return Err(OpenApiReviewError::CatalogLimit);
1889            }
1890        }
1891    }
1892    Ok(OpenApiSecurityMetadata {
1893        alternatives: requirements.len(),
1894        permits_anonymous,
1895        declares_auth,
1896        scheme_kinds: kinds.into_iter().collect(),
1897        scope_count,
1898    })
1899}
1900
1901fn parse_servers(
1902    value: Option<&Value>,
1903    origin: &Url,
1904) -> Result<Vec<OpenApiServerMetadata>, OpenApiReviewError> {
1905    let Some(values) = value else {
1906        return Ok(Vec::new());
1907    };
1908    let values = values
1909        .as_array()
1910        .ok_or(OpenApiReviewError::InvalidOperation)?;
1911    if values.len() > MAX_OPENAPI_SERVERS {
1912        return Err(OpenApiReviewError::CatalogLimit);
1913    }
1914    let mut servers = Vec::with_capacity(values.len());
1915    for value in values {
1916        let object = value
1917            .as_object()
1918            .ok_or(OpenApiReviewError::InvalidOperation)?;
1919        let url = bounded_token(object.get("url"))?;
1920        let variables = object
1921            .get("variables")
1922            .map(|value| {
1923                value
1924                    .as_object()
1925                    .ok_or(OpenApiReviewError::InvalidOperation)
1926            })
1927            .transpose()?;
1928        if variables.is_some_and(|values| values.values().any(|value| !value.is_object())) {
1929            return Err(OpenApiReviewError::InvalidOperation);
1930        }
1931        let variable_count = variables.map_or(0, Map::len);
1932        if variable_count > MAX_OPENAPI_SECURITY_REQUIREMENTS {
1933            return Err(OpenApiReviewError::CatalogLimit);
1934        }
1935        let normalized_input = url.trim().replace('\\', "/");
1936        let (kind, canonical_value, execution_base) =
1937            if normalized_input.contains('{') || normalized_input.contains('}') {
1938                let names = server_template_names(&normalized_input)?;
1939                let declared = variables
1940                    .map(|values| values.keys().map(String::as_str).collect::<BTreeSet<_>>())
1941                    .unwrap_or_default();
1942                if names != declared {
1943                    return Err(OpenApiReviewError::InvalidOperation);
1944                }
1945                (OpenApiServerKind::Templated, normalized_input, None)
1946            } else {
1947                let absolute = Url::parse(&normalized_input).ok();
1948                let network_path = normalized_input.starts_with("//");
1949                let resolved = absolute
1950                    .clone()
1951                    .map(Ok)
1952                    .unwrap_or_else(|| origin.join(&normalized_input));
1953                match resolved {
1954                    Ok(server) if !matches!(server.scheme(), "http" | "https") => {
1955                        (OpenApiServerKind::Unsupported, server.to_string(), None)
1956                    },
1957                    Ok(server) if !same_origin(&server, origin) => {
1958                        (OpenApiServerKind::CrossOrigin, server.to_string(), None)
1959                    },
1960                    Ok(server) if absolute.is_some() || network_path => {
1961                        let execution_base =
1962                            execution_server_base_is_safe(&server).then(|| server.clone());
1963                        (
1964                            OpenApiServerKind::ExactOrigin,
1965                            server.to_string(),
1966                            execution_base,
1967                        )
1968                    },
1969                    Ok(server) => {
1970                        let execution_base =
1971                            execution_server_base_is_safe(&server).then(|| server.clone());
1972                        (
1973                            OpenApiServerKind::Relative,
1974                            server.to_string(),
1975                            execution_base,
1976                        )
1977                    },
1978                    Err(_) => (OpenApiServerKind::Unsupported, normalized_input, None),
1979                }
1980            };
1981        servers.push(OpenApiServerMetadata {
1982            kind,
1983            variable_count,
1984            canonical_identity: digest_bytes(
1985                b"openapi-canonical-server/v1",
1986                canonical_value.as_bytes(),
1987            ),
1988            execution_base,
1989        });
1990    }
1991    servers.sort_unstable();
1992    servers.dedup();
1993    Ok(servers)
1994}
1995
1996fn execution_server_base_is_safe(server: &Url) -> bool {
1997    let path = server.path().to_ascii_lowercase();
1998    server.username().is_empty()
1999        && server.password().is_none()
2000        && server.query().is_none()
2001        && server.fragment().is_none()
2002        && !path.contains("%2f")
2003        && !path.contains("%5c")
2004        && !path.contains("%2e")
2005        && !server.path().contains(['\\', '\r', '\n', '\0'])
2006        && !server.path().contains("//")
2007}
2008
2009fn server_template_names(value: &str) -> Result<BTreeSet<&str>, OpenApiReviewError> {
2010    let mut names = BTreeSet::new();
2011    for name in braced_tokens(value).map_err(|()| OpenApiReviewError::InvalidOperation)? {
2012        if name.is_empty()
2013            || (!name.as_bytes()[0].is_ascii_alphabetic() && name.as_bytes()[0] != b'_')
2014            || !name
2015                .bytes()
2016                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
2017            || !names.insert(name)
2018        {
2019            return Err(OpenApiReviewError::InvalidOperation);
2020        }
2021    }
2022    Ok(names)
2023}
2024
2025fn braced_tokens(value: &str) -> Result<Vec<&str>, ()> {
2026    let mut tokens = Vec::new();
2027    let mut start = None;
2028    for (index, byte) in value.bytes().enumerate() {
2029        match (byte, start) {
2030            (b'{', None) => start = Some(index + 1),
2031            (b'{', Some(_)) | (b'}', None) => return Err(()),
2032            (b'}', Some(token_start)) => {
2033                tokens.push(&value[token_start..index]);
2034                start = None;
2035            },
2036            _ => {},
2037        }
2038    }
2039    if start.is_some() {
2040        return Err(());
2041    }
2042    Ok(tokens)
2043}
2044
2045fn same_origin(left: &Url, right: &Url) -> bool {
2046    left.scheme() == right.scheme()
2047        && left.host_str() == right.host_str()
2048        && left.port_or_known_default() == right.port_or_known_default()
2049}
2050
2051fn bounded_token(value: Option<&Value>) -> Result<&str, OpenApiReviewError> {
2052    let value = value
2053        .and_then(Value::as_str)
2054        .ok_or(OpenApiReviewError::InvalidOperation)?;
2055    if value.is_empty()
2056        || value.len() > MAX_OPENAPI_TOKEN_BYTES
2057        || value.contains(['\r', '\n', '\0'])
2058    {
2059        return Err(OpenApiReviewError::InvalidOperation);
2060    }
2061    Ok(value)
2062}
2063
2064fn candidate_tags(
2065    method: OpenApiHttpMethod,
2066    parameters: &[OpenApiParameterMetadata],
2067    request_body_declared: bool,
2068    request_media: &[String],
2069    responses: &[OpenApiResponseMetadata],
2070    security: &OpenApiSecurityMetadata,
2071    deprecated: bool,
2072) -> Vec<OpenApiCandidateTag> {
2073    let mut tags = BTreeSet::new();
2074    if matches!(
2075        method,
2076        OpenApiHttpMethod::Get | OpenApiHttpMethod::Head | OpenApiHttpMethod::Options
2077    ) {
2078        tags.insert(OpenApiCandidateTag::ReadOnly);
2079    }
2080    if request_body_declared {
2081        tags.insert(OpenApiCandidateTag::BodyBearing);
2082    }
2083    if !parameters.is_empty() {
2084        tags.insert(OpenApiCandidateTag::Parameterized);
2085    }
2086    if security.permits_anonymous {
2087        tags.insert(OpenApiCandidateTag::DeclaresAnonymousAccess);
2088    }
2089    if security.declares_auth {
2090        tags.insert(OpenApiCandidateTag::DeclaresSecurity);
2091    }
2092    if request_media.iter().any(|value| {
2093        matches!(
2094            media_family(value),
2095            OpenApiMediaFamily::Json | OpenApiMediaFamily::JsonSuffix
2096        )
2097    }) {
2098        tags.insert(OpenApiCandidateTag::JsonRequest);
2099    }
2100    if responses
2101        .iter()
2102        .flat_map(|response| response.media_types.iter())
2103        .any(|value| {
2104            matches!(
2105                media_family(value),
2106                OpenApiMediaFamily::Json | OpenApiMediaFamily::JsonSuffix
2107            )
2108        })
2109    {
2110        tags.insert(OpenApiCandidateTag::JsonResponse);
2111    }
2112    if deprecated {
2113        tags.insert(OpenApiCandidateTag::Deprecated);
2114    }
2115    if method == OpenApiHttpMethod::Get && security.declares_auth {
2116        tags.insert(OpenApiCandidateTag::AuthorizationReviewCandidate);
2117    }
2118    if parameters.iter().any(|parameter| {
2119        matches!(
2120            parameter.location,
2121            OpenApiParameterLocation::Query | OpenApiParameterLocation::Path
2122        ) && matches!(
2123            parameter.schema_kind,
2124            OpenApiSchemaPrimitiveKind::String
2125                | OpenApiSchemaPrimitiveKind::Integer
2126                | OpenApiSchemaPrimitiveKind::Number
2127                | OpenApiSchemaPrimitiveKind::Unknown
2128        )
2129    }) {
2130        tags.insert(OpenApiCandidateTag::SqlInputCandidate);
2131    }
2132    if parameters.iter().any(|parameter| {
2133        matches!(
2134            parameter.format_class,
2135            OpenApiFormatClass::Uri | OpenApiFormatClass::Url | OpenApiFormatClass::Hostname
2136        )
2137    }) {
2138        tags.insert(OpenApiCandidateTag::SsrfUrlCandidate);
2139    }
2140    if request_media.iter().any(|media| {
2141        matches!(
2142            media_family(media),
2143            OpenApiMediaFamily::Multipart | OpenApiMediaFamily::Binary
2144        )
2145    }) || parameters
2146        .iter()
2147        .any(|parameter| parameter.format_class == OpenApiFormatClass::Binary)
2148    {
2149        tags.insert(OpenApiCandidateTag::UploadCandidate);
2150    }
2151    if security.scheme_kinds.iter().any(|kind| {
2152        matches!(
2153            kind,
2154            OpenApiSecuritySchemeKind::OAuth2 | OpenApiSecuritySchemeKind::OpenIdConnect
2155        )
2156    }) {
2157        tags.insert(OpenApiCandidateTag::OAuthCandidate);
2158    }
2159    if responses
2160        .iter()
2161        .flat_map(|response| &response.media_types)
2162        .any(|media| media_family(media) == OpenApiMediaFamily::Binary)
2163    {
2164        tags.insert(OpenApiCandidateTag::BinaryResponse);
2165    }
2166    tags.into_iter().collect()
2167}
2168
2169struct OperationIdentityInput<'a> {
2170    path: &'a str,
2171    method: OpenApiHttpMethod,
2172    parameters: &'a [OpenApiParameterMetadata],
2173    request_body_declared: bool,
2174    request_media: &'a [String],
2175    responses: &'a [OpenApiResponseMetadata],
2176    security: &'a OpenApiSecurityMetadata,
2177    security_source: OpenApiSecuritySource,
2178    servers: &'a [OpenApiServerMetadata],
2179    tags: &'a [OpenApiCandidateTag],
2180    declared_operation_id: Option<&'a [u8; 32]>,
2181    deprecated: bool,
2182}
2183
2184fn operation_id(input: OperationIdentityInput<'_>) -> OpenApiOperationId {
2185    let mut digest = Sha256::new();
2186    update_framed(&mut digest, b"security.openapi-operation/v1");
2187    update_framed(&mut digest, input.path.as_bytes());
2188    update_framed(&mut digest, input.method.token().as_bytes());
2189    frame_operation_metadata(&mut digest, &input);
2190    update_framed(&mut digest, input.security_source.token().as_bytes());
2191    update_framed(
2192        &mut digest,
2193        input
2194            .declared_operation_id
2195            .map_or(&[][..], |value| &value[..]),
2196    );
2197    update_framed(&mut digest, &[u8::from(input.deprecated)]);
2198    OpenApiOperationId(format!(
2199        "openapi-operation-sha256:{}",
2200        hex(&digest.finalize())
2201    ))
2202}
2203
2204fn scoped_operation_id(document_identity: &str, local: &OpenApiOperationId) -> OpenApiOperationId {
2205    let mut digest = Sha256::new();
2206    update_framed(&mut digest, b"security.openapi-document-operation/v1");
2207    update_framed(&mut digest, document_identity.as_bytes());
2208    update_framed(&mut digest, local.as_str().as_bytes());
2209    OpenApiOperationId(format!(
2210        "openapi-operation-sha256:{}",
2211        hex(&digest.finalize())
2212    ))
2213}
2214
2215fn document_digest(
2216    kind: OpenApiDocumentKind,
2217    root_security_declared: bool,
2218    path_count: usize,
2219    root_security: &OpenApiSecurityMetadata,
2220    servers: &[OpenApiServerMetadata],
2221    security_schemes: &[OpenApiSecuritySchemeKind],
2222    catalog: &OpenApiCatalog,
2223) -> String {
2224    let mut digest = Sha256::new();
2225    update_framed(&mut digest, OPENAPI_CATALOG_ALGORITHM.as_bytes());
2226    update_framed(&mut digest, kind.token().as_bytes());
2227    update_framed(&mut digest, &[u8::from(root_security_declared)]);
2228    frame_count(&mut digest, path_count);
2229    frame_count(&mut digest, root_security.alternatives);
2230    update_framed(&mut digest, &[u8::from(root_security.permits_anonymous)]);
2231    update_framed(&mut digest, &[u8::from(root_security.declares_auth)]);
2232    frame_count(&mut digest, root_security.scope_count);
2233    for scheme in &root_security.scheme_kinds {
2234        update_framed(&mut digest, scheme.token().as_bytes());
2235    }
2236    for server in servers {
2237        frame_server(&mut digest, server);
2238    }
2239    for scheme in security_schemes {
2240        update_framed(&mut digest, scheme.token().as_bytes());
2241    }
2242    for operation in &catalog.operations {
2243        update_framed(&mut digest, operation.id.as_str().as_bytes());
2244    }
2245    format!("openapi-catalog-sha256:{}", hex(&digest.finalize()))
2246}
2247
2248fn frame_operation_metadata(digest: &mut Sha256, input: &OperationIdentityInput<'_>) {
2249    for parameter in input.parameters {
2250        update_framed(digest, parameter.location.token().as_bytes());
2251        update_framed(digest, &[u8::from(parameter.required)]);
2252        update_framed(digest, &parameter.name_digest);
2253        update_framed(digest, parameter.schema_kind.token().as_bytes());
2254        update_framed(digest, parameter.format_class.token().as_bytes());
2255    }
2256    update_framed(digest, &[u8::from(input.request_body_declared)]);
2257    for media in input.request_media {
2258        update_framed(digest, media.as_bytes());
2259    }
2260    for response in input.responses {
2261        update_framed(digest, response.status.token().as_bytes());
2262        match response.status {
2263            OpenApiResponseStatus::Exact(value) => update_framed(digest, &value.to_be_bytes()),
2264            OpenApiResponseStatus::Range(value) => update_framed(digest, &[value]),
2265            OpenApiResponseStatus::Default => update_framed(digest, &[]),
2266        }
2267        for media in &response.media_types {
2268            update_framed(digest, media.as_bytes());
2269        }
2270    }
2271    frame_count(digest, input.security.alternatives);
2272    update_framed(digest, &[u8::from(input.security.permits_anonymous)]);
2273    update_framed(digest, &[u8::from(input.security.declares_auth)]);
2274    frame_count(digest, input.security.scope_count);
2275    for kind in &input.security.scheme_kinds {
2276        update_framed(digest, kind.token().as_bytes());
2277    }
2278    for server in input.servers {
2279        frame_server(digest, server);
2280    }
2281    for tag in input.tags {
2282        update_framed(digest, tag.token().as_bytes());
2283    }
2284}
2285
2286fn frame_server(digest: &mut Sha256, server: &OpenApiServerMetadata) {
2287    update_framed(digest, server.kind.token().as_bytes());
2288    frame_count(digest, server.variable_count);
2289    update_framed(digest, &server.canonical_identity);
2290}
2291fn digest_bytes(domain: &[u8], value: &[u8]) -> [u8; 32] {
2292    let mut digest = Sha256::new();
2293    update_framed(&mut digest, domain);
2294    update_framed(&mut digest, value);
2295    digest.finalize().into()
2296}
2297fn update_framed(digest: &mut Sha256, value: &[u8]) {
2298    digest.update(
2299        u64::try_from(value.len())
2300            .expect("bounded field length fits u64")
2301            .to_be_bytes(),
2302    );
2303    digest.update(value);
2304}
2305fn frame_count(digest: &mut Sha256, value: usize) {
2306    update_framed(
2307        digest,
2308        &u64::try_from(value)
2309            .expect("bounded count fits u64")
2310            .to_be_bytes(),
2311    );
2312}
2313fn hex(bytes: &[u8]) -> String {
2314    const HEX: &[u8; 16] = b"0123456789abcdef";
2315    let mut value = String::with_capacity(bytes.len() * 2);
2316    for byte in bytes {
2317        value.push(HEX[(byte >> 4) as usize] as char);
2318        value.push(HEX[(byte & 0xf) as usize] as char);
2319    }
2320    value
2321}
2322
2323#[cfg(test)]
2324mod tests {
2325    use super::*;
2326    use serde_json::json;
2327
2328    fn document() -> Value {
2329        json!({
2330            "openapi": "3.1.0",
2331            "info": {"title":"must not be retained", "version":"1", "description":"SECRET PROSE"},
2332            "servers": [{"url":"https://api.example.test/{version}","variables":{"version":{"default":"v1"}}}],
2333            "components": {"securitySchemes": {
2334                "header-secret": {"type":"apiKey","in":"header","name":"X-Key"},
2335                "oauth": {"type":"oauth2","flows":{}}
2336            }},
2337            "security": [{"header-secret":[]}],
2338            "paths": {
2339                "/pets/{petId}": {
2340                    "parameters": [{"name":"petId","in":"path","required":true,"schema":{"type":"string"}}],
2341                    "get": {
2342                        "operationId":"secret-operation-name", "tags":["secret-tag"], "summary":"SECRET PROSE",
2343                        "parameters":[{"name":"trace","in":"query"}],
2344                        "responses":{"200":{"description":"ok","content":{"Application/Problem+JSON; charset=utf-8": {"example":{"secret":"value"}}}}}
2345                    },
2346                    "post": {
2347                        "security": [], "deprecated":true,
2348                        "requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},
2349                        "responses":{"201":{"description":"ok","content":{"application/json":{}}}}
2350                    }
2351                },
2352                "/health": {"get":{"responses":{"204":{"description":"ok"}}}}
2353            }
2354        })
2355    }
2356
2357    fn parse(value: &Value) -> OpenApiDocument {
2358        OpenApiDocument::parse_json(&serde_json::to_vec(value).unwrap()).unwrap()
2359    }
2360
2361    #[test]
2362    fn parses_reduced_openapi_31_catalog_and_queries() {
2363        let parsed = parse(&document());
2364        assert_eq!(parsed.kind(), OpenApiDocumentKind::OpenApi31);
2365        assert_eq!(parsed.catalog().operations().len(), 3);
2366        assert_eq!(parsed.catalog().by_method(OpenApiHttpMethod::Get).len(), 2);
2367        assert_eq!(
2368            parsed
2369                .catalog()
2370                .with_candidate_tag(OpenApiCandidateTag::ReadOnly)
2371                .len(),
2372            2
2373        );
2374        let post = parsed.catalog().by_method(OpenApiHttpMethod::Post)[0];
2375        assert!(post
2376            .candidate_tags()
2377            .contains(&OpenApiCandidateTag::JsonRequest));
2378        assert!(post
2379            .candidate_tags()
2380            .contains(&OpenApiCandidateTag::Deprecated));
2381        assert!(post.security().permits_anonymous());
2382        let get = parsed
2383            .catalog()
2384            .operations()
2385            .iter()
2386            .find(|operation| {
2387                operation.path() == "/pets/{petId}" && operation.method() == OpenApiHttpMethod::Get
2388            })
2389            .unwrap();
2390        assert_eq!(get.parameters().len(), 2);
2391        assert_eq!(
2392            get.responses()[0].media_types(),
2393            &["application/problem+json"]
2394        );
2395        assert_eq!(parsed.servers()[0].kind(), OpenApiServerKind::Templated);
2396        assert_eq!(
2397            parsed.security_schemes(),
2398            &[
2399                OpenApiSecuritySchemeKind::ApiKeyHeader,
2400                OpenApiSecuritySchemeKind::OAuth2
2401            ]
2402        );
2403        assert!(parsed.catalog().operation(get.id()).is_some());
2404    }
2405
2406    #[test]
2407    fn supports_openapi_30_and_classifies_swagger_without_operations() {
2408        let mut value = document();
2409        value["openapi"] = json!("3.0.3");
2410        assert_eq!(parse(&value).kind(), OpenApiDocumentKind::OpenApi30);
2411        let swagger = parse(&json!({"swagger":"2.0","paths":{"/legacy":{"get":{}}}}));
2412        assert_eq!(swagger.kind(), OpenApiDocumentKind::Swagger20MetadataOnly);
2413        assert!(!swagger.kind().executable_catalog());
2414        assert!(swagger.catalog().operations().is_empty());
2415    }
2416
2417    #[test]
2418    fn source_order_and_unretained_prose_do_not_change_identity() {
2419        let first = parse(&document());
2420        let mut changed = document();
2421        changed["info"]["description"] = json!("different");
2422        changed["paths"]["/pets/{petId}"]["get"]["summary"] = json!("different");
2423        let second = parse(&changed);
2424        assert_eq!(first.digest(), second.digest());
2425        assert_eq!(first.catalog(), second.catalog());
2426        assert!(!format!("{first:?}").contains("SECRET"));
2427        assert!(!format!("{first:?}").contains("secret-operation-name"));
2428        assert!(!format!("{first:?}").contains("api.example.test"));
2429    }
2430
2431    #[test]
2432    fn material_metadata_changes_document_and_operation_identity() {
2433        let first = parse(&document());
2434        let mut changed = document();
2435        changed["paths"]["/pets/{petId}"]["get"]["responses"]["200"]["content"] =
2436            json!({"text/plain":{}});
2437        let second = parse(&changed);
2438        assert_ne!(first.digest(), second.digest());
2439        assert_ne!(
2440            first.catalog().by_method(OpenApiHttpMethod::Get)[1].id(),
2441            second.catalog().by_method(OpenApiHttpMethod::Get)[1].id()
2442        );
2443    }
2444
2445    #[test]
2446    fn facade_classifies_complete_swagger_version_malformed_limits_and_size() {
2447        let origin = Url::parse("https://api.example.test/base").unwrap();
2448        let bytes = serde_json::to_vec(&document()).unwrap();
2449        let OpenApiParseOutcome::Complete(parsed) = parse_openapi_document(&bytes, &origin) else {
2450            panic!("OpenAPI 3.1 document should complete");
2451        };
2452        assert_eq!(parsed.version(), Some(OpenApiVersion::OpenApi31));
2453        assert_eq!(parsed.path_count(), 2);
2454        assert_eq!(parsed.operation_count(), 3);
2455        assert_eq!(parsed.semantic_digest(), parsed.digest());
2456        assert_eq!(
2457            parse_openapi_document(br#"{"swagger":"2.0"}"#, &origin),
2458            OpenApiParseOutcome::Swagger20MetadataOnly
2459        );
2460        assert_eq!(
2461            parse_openapi_document(br#"{"openapi":"4.0.0","paths":{}}"#, &origin),
2462            OpenApiParseOutcome::UnsupportedVersion
2463        );
2464        assert_eq!(
2465            parse_openapi_document(b"openapi: 3.1.0", &origin),
2466            OpenApiParseOutcome::Malformed
2467        );
2468        assert_eq!(
2469            parse_openapi_document(br#"{"openapi":"3.1.0","paths":{"relative":{}}}"#, &origin),
2470            OpenApiParseOutcome::LimitExceeded
2471        );
2472        assert_eq!(
2473            parse_openapi_document(&vec![b' '; MAX_OPENAPI_DOCUMENT_BYTES + 1], &origin),
2474            OpenApiParseOutcome::TooLarge
2475        );
2476    }
2477
2478    #[test]
2479    fn duplicate_json_keys_fail_closed() {
2480        assert_eq!(
2481            OpenApiDocument::parse_json(br#"{"openapi":"3.1.0","paths":{},"paths":{}}"#),
2482            Err(OpenApiReviewError::MalformedJson)
2483        );
2484    }
2485
2486    #[test]
2487    fn server_classification_uses_document_origin_without_retaining_urls() {
2488        let origin = Url::parse("https://api.example.test/source").unwrap();
2489        let body = br#"{"openapi":"3.1.0","servers":[{"url":"https://api.example.test/v1"},{"url":"https://other.test/v1"},{"url":"/v1"},{"url":"https://{tenant}.example.test","variables":{"tenant":{"default":"api"}}},{"url":"ftp://example.test"}],"paths":{}}"#;
2490        let OpenApiParseOutcome::Complete(parsed) = parse_openapi_document(body, &origin) else {
2491            panic!("expected complete")
2492        };
2493        let kinds = parsed
2494            .servers()
2495            .iter()
2496            .map(OpenApiServerMetadata::kind)
2497            .collect::<BTreeSet<_>>();
2498        assert_eq!(
2499            kinds,
2500            BTreeSet::from([
2501                OpenApiServerKind::ExactOrigin,
2502                OpenApiServerKind::CrossOrigin,
2503                OpenApiServerKind::Relative,
2504                OpenApiServerKind::Templated,
2505                OpenApiServerKind::Unsupported
2506            ])
2507        );
2508        let debug = format!("{parsed:?}");
2509        assert!(!debug.contains("api.example.test"));
2510        assert!(!debug.contains("other.test"));
2511    }
2512
2513    #[test]
2514    fn rejects_ambiguous_paths_and_binds_declared_operation_identity() {
2515        for path in [
2516            "/a/../b",
2517            "/a//b",
2518            "/a%2Fb",
2519            "/{id}/{id}",
2520            "/{bad.name}",
2521            "/{open",
2522            "/stray}",
2523            "/{outer{inner}}",
2524            "/{{id}}",
2525            "/{id}}",
2526        ] {
2527            let value = json!({"openapi":"3.1.0","paths":{path:{"get":{}}}});
2528            assert_eq!(
2529                OpenApiDocument::parse_json(&serde_json::to_vec(&value).unwrap()),
2530                Err(OpenApiReviewError::InvalidPaths)
2531            );
2532        }
2533        let first = parse(&document());
2534        let mut second_value = document();
2535        second_value["paths"]["/pets/{petId}"]["get"]["operationId"] = json!("different");
2536        let second = parse(&second_value);
2537        let first_get = first
2538            .catalog()
2539            .operations()
2540            .iter()
2541            .find(|operation| {
2542                operation.path() == "/pets/{petId}" && operation.method() == OpenApiHttpMethod::Get
2543            })
2544            .unwrap();
2545        let second_get = second
2546            .catalog()
2547            .operations()
2548            .iter()
2549            .find(|operation| {
2550                operation.path() == "/pets/{petId}" && operation.method() == OpenApiHttpMethod::Get
2551            })
2552            .unwrap();
2553        assert!(first_get.has_declared_operation_id());
2554        assert_ne!(first_get.id(), second_get.id());
2555    }
2556
2557    #[test]
2558    fn versions_types_and_parameter_override_fail_closed_or_reduce_exactly() {
2559        for version in ["3.1.", "3.1.foo", "3.0.1-beta", "3.2.0"] {
2560            let value = json!({"openapi":version,"paths":{}});
2561            assert_eq!(
2562                OpenApiDocument::parse_json(&serde_json::to_vec(&value).unwrap()),
2563                Err(OpenApiReviewError::UnsupportedVersion)
2564            );
2565        }
2566        for value in [
2567            json!({"openapi":"3.1.0","components":[],"paths":{}}),
2568            json!({"openapi":"3.1.0","components":{"securitySchemes":[]},"paths":{}}),
2569            json!({"openapi":"3.1.0","paths":{"/x":{"get":{"requestBody":[],"responses":{}}}}}),
2570            json!({"openapi":"3.1.0","paths":{"/x":{"get":{"responses":[]}}}}),
2571        ] {
2572            assert_eq!(
2573                OpenApiDocument::parse_json(&serde_json::to_vec(&value).unwrap()),
2574                Err(OpenApiReviewError::InvalidOperation)
2575            );
2576        }
2577
2578        let value = json!({"openapi":"3.1.0","paths":{"/x":{
2579            "parameters":[{"name":"id","in":"query","schema":{"type":"string"}}],
2580            "get":{"parameters":[{"name":"id","in":"query","schema":{"type":"integer"}}],"responses":{}}
2581        }}});
2582        let parsed = parse(&value);
2583        assert_eq!(parsed.catalog().operations()[0].parameters().len(), 1);
2584        assert_eq!(
2585            parsed.catalog().operations()[0].parameters()[0].schema_kind(),
2586            OpenApiSchemaPrimitiveKind::Integer
2587        );
2588    }
2589
2590    #[test]
2591    fn explicit_empty_path_servers_override_root_servers() {
2592        let origin = Url::parse("https://api.example.test/").unwrap();
2593        let body = br#"{"openapi":"3.1.0","servers":[{"url":"https://api.example.test"}],"paths":{"/x":{"servers":[],"get":{"responses":{}}}}}"#;
2594        let OpenApiParseOutcome::Complete(parsed) = parse_openapi_document(body, &origin) else {
2595            panic!("expected complete")
2596        };
2597        assert_eq!(parsed.servers().len(), 1);
2598        assert!(parsed.catalog().operations()[0].servers().is_empty());
2599    }
2600
2601    #[test]
2602    fn duplicate_parameter_identity_is_rejected() {
2603        let value = json!({"openapi":"3.1.0","paths":{"/x":{"get":{"parameters":[{"name":"id","in":"query"},{"name":"id","in":"query"}],"responses":{}}}}});
2604        assert_eq!(
2605            OpenApiDocument::parse_json(&serde_json::to_vec(&value).unwrap()),
2606            Err(OpenApiReviewError::InvalidOperation)
2607        );
2608    }
2609
2610    #[test]
2611    fn schema_security_and_future_candidate_metadata_are_bounded() {
2612        let mut value = document();
2613        value["paths"]["/pets/{petId}"]["get"]["parameters"][0]["schema"] =
2614            json!({"type":"string","format":"uri"});
2615        value["paths"]["/pets/{petId}"]["get"]["responses"]["200"]["content"] =
2616            json!({"application/octet-stream":{}});
2617        let parsed = parse(&value);
2618        let get = parsed
2619            .catalog()
2620            .operations()
2621            .iter()
2622            .find(|operation| {
2623                operation.path() == "/pets/{petId}" && operation.method() == OpenApiHttpMethod::Get
2624            })
2625            .unwrap();
2626        let query = get
2627            .parameters()
2628            .iter()
2629            .find(|parameter| parameter.location() == OpenApiParameterLocation::Query)
2630            .expect("query parameter");
2631        assert_eq!(query.schema_kind(), OpenApiSchemaPrimitiveKind::String);
2632        assert_eq!(query.format_class(), OpenApiFormatClass::Uri);
2633        for tag in [
2634            OpenApiCandidateTag::AuthorizationReviewCandidate,
2635            OpenApiCandidateTag::SqlInputCandidate,
2636            OpenApiCandidateTag::SsrfUrlCandidate,
2637            OpenApiCandidateTag::BinaryResponse,
2638        ] {
2639            assert!(get.candidate_tags().contains(&tag));
2640        }
2641        assert!(parsed.title_present());
2642        assert_eq!(parsed.root_security().alternatives(), 1);
2643        assert_eq!(parsed.root_security().scope_count(), 0);
2644        assert!(!get.deprecated());
2645    }
2646
2647    #[test]
2648    fn rejects_size_malformed_versions_external_refs_and_invalid_paths() {
2649        assert_eq!(
2650            OpenApiDocument::parse_json(&vec![b' '; MAX_OPENAPI_DOCUMENT_BYTES + 1]).unwrap_err(),
2651            OpenApiReviewError::DocumentSize
2652        );
2653        assert_eq!(
2654            OpenApiDocument::parse_json(b"{").unwrap_err(),
2655            OpenApiReviewError::MalformedJson
2656        );
2657        assert_eq!(
2658            OpenApiDocument::parse_json(br#"{"openapi":"2.0","paths":{}}"#).unwrap_err(),
2659            OpenApiReviewError::UnsupportedVersion
2660        );
2661        assert_eq!(OpenApiDocument::parse_json(br#"{"openapi":"3.1.0","paths":{},"components":{"schemas":{"x":{"$ref":"https://example.test/x"}}}}"#).unwrap_err(), OpenApiReviewError::ExternalReference);
2662        assert_eq!(
2663            OpenApiDocument::parse_json(br#"{"openapi":"3.1.0","paths":{"relative":{"get":{}}}}"#)
2664                .unwrap_err(),
2665            OpenApiReviewError::InvalidPaths
2666        );
2667    }
2668
2669    #[test]
2670    fn enforces_every_json_structure_limit() {
2671        let base = OpenApiDocumentLimits::default();
2672        let mut value = document();
2673        value["paths"]["/pets/{petId}"]["get"]["tags"] = json!(["one", "two"]);
2674        let body = serde_json::to_vec(&value).unwrap();
2675        for limits in [
2676            OpenApiDocumentLimits { depth: 2, ..base },
2677            OpenApiDocumentLimits { nodes: 2, ..base },
2678            OpenApiDocumentLimits {
2679                object_members: 2,
2680                ..base
2681            },
2682            OpenApiDocumentLimits {
2683                array_length: 1,
2684                ..base
2685            },
2686            OpenApiDocumentLimits {
2687                string_bytes: 4,
2688                ..base
2689            },
2690        ] {
2691            assert_eq!(
2692                OpenApiDocument::parse_json_with_limits(&body, limits,).unwrap_err(),
2693                OpenApiReviewError::JsonLimit
2694            );
2695        }
2696        assert_eq!(
2697            OpenApiDocumentLimits {
2698                document_bytes: 0,
2699                ..base
2700            }
2701            .checked()
2702            .unwrap_err(),
2703            OpenApiReviewError::InvalidLimits
2704        );
2705    }
2706
2707    #[test]
2708    fn rejects_invalid_parameter_media_response_and_catalog_bounds() {
2709        let mut path_parameter = document();
2710        path_parameter["paths"]["/pets/{petId}"]["parameters"][0]["required"] = json!(false);
2711        assert_eq!(
2712            OpenApiDocument::parse_json(&serde_json::to_vec(&path_parameter).unwrap()).unwrap_err(),
2713            OpenApiReviewError::InvalidOperation
2714        );
2715        let mut media = document();
2716        media["paths"]["/health"]["get"]["responses"]["204"]["content"] = json!({"invalid":{}});
2717        assert_eq!(
2718            OpenApiDocument::parse_json(&serde_json::to_vec(&media).unwrap()).unwrap_err(),
2719            OpenApiReviewError::InvalidOperation
2720        );
2721        let limits = OpenApiDocumentLimits {
2722            paths: 1,
2723            ..OpenApiDocumentLimits::default()
2724        };
2725        assert_eq!(
2726            OpenApiDocument::parse_json_with_limits(
2727                &serde_json::to_vec(&document()).unwrap(),
2728                limits
2729            )
2730            .unwrap_err(),
2731            OpenApiReviewError::CatalogLimit
2732        );
2733    }
2734
2735    #[test]
2736    fn local_references_are_metadata_only_and_unknown_extensions_are_ignored() {
2737        let parsed = parse(
2738            &json!({"openapi":"3.1.0","x-vendor":{"anything":true},"paths":{"/pets":{"get":{"parameters":[{"$ref":"#/components/parameters/Trace"}],"responses":{"200":{"$ref":"#/components/responses/Pets"}}}}},"components":{"parameters":{"Trace":{"name":"trace","in":"query"}},"responses":{"Pets":{"description":"ok"}}}}),
2739        );
2740        assert_eq!(parsed.catalog().operations().len(), 1);
2741        assert_eq!(parsed.catalog().operations()[0].parameters().len(), 1);
2742    }
2743
2744    #[test]
2745    fn security_declarations_are_metadata_not_authorization_claims() {
2746        let parsed = parse(&document());
2747        let secured = parsed
2748            .catalog()
2749            .by_method(OpenApiHttpMethod::Get)
2750            .into_iter()
2751            .find(|operation| operation.path() == "/pets/{petId}")
2752            .unwrap();
2753        assert!(!secured.security().permits_anonymous());
2754        assert_eq!(secured.security().alternatives(), 1);
2755        assert_eq!(
2756            secured.security().scheme_kinds(),
2757            &[OpenApiSecuritySchemeKind::ApiKeyHeader]
2758        );
2759
2760        let requirement = (0..=MAX_OPENAPI_SECURITY_REQUIREMENTS)
2761            .map(|index| (format!("scheme-{index}"), json!([])))
2762            .collect::<Map<_, _>>();
2763        let over_limit = json!({
2764            "openapi": "3.1.0",
2765            "security": [Value::Object(requirement)],
2766            "paths": {},
2767        });
2768        assert_eq!(
2769            OpenApiDocument::parse_json(&serde_json::to_vec(&over_limit).unwrap()).unwrap_err(),
2770            OpenApiReviewError::CatalogLimit
2771        );
2772    }
2773
2774    #[test]
2775    fn media_status_response_and_catalog_summaries_are_typed() {
2776        let value = json!({"openapi":"3.1.0","components":{"securitySchemes":{"basic":{"type":"http","scheme":"basic"}}},"security":[{"basic":[]}],"paths":{"/x/{id}":{"get":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"hostname"}},{"name":"q","in":"query","schema":{"type":"string","format":"date"}}],"responses":{"200":{"content":{"application/json":{}}},"401":{"content":{"application/problem+json":{}}},"4XX":{"content":{"text/plain":{}}},"500":{"content":{"application/octet-stream":{}}},"default":{}}}}}});
2777        let parsed = parse(&value);
2778        let operation = &parsed.catalog().operations()[0];
2779        let summary = operation.response_summary();
2780        assert!(
2781            summary.success()
2782                && summary.authentication()
2783                && summary.client_error()
2784                && summary.server_error()
2785        );
2786        assert_eq!(
2787            operation.responses()[0].status(),
2788            OpenApiResponseStatus::Exact(200)
2789        );
2790        assert!(summary.media_families().contains(&OpenApiMediaFamily::Json));
2791        assert!(summary
2792            .media_families()
2793            .contains(&OpenApiMediaFamily::JsonSuffix));
2794        assert!(summary
2795            .media_families()
2796            .contains(&OpenApiMediaFamily::Binary));
2797        let catalog = parsed.catalog().summary();
2798        assert_eq!(catalog.path_parameter_count, 1);
2799        assert_eq!(catalog.query_parameter_count, 1);
2800        assert_eq!(catalog.url_like_parameter_count, 1);
2801        assert_eq!(catalog.explicit_auth_operation_count, 1);
2802        assert_eq!(catalog.anonymous_operation_count, 0);
2803        assert_eq!(catalog.json_compatible_operation_count, 1);
2804        assert_eq!(
2805            operation
2806                .parameters()
2807                .iter()
2808                .find(|parameter| parameter.location() == OpenApiParameterLocation::Path)
2809                .unwrap()
2810                .format_class(),
2811            OpenApiFormatClass::Hostname
2812        );
2813        assert_eq!(
2814            operation
2815                .parameters()
2816                .iter()
2817                .find(|parameter| parameter.location() == OpenApiParameterLocation::Query)
2818                .unwrap()
2819                .format_class(),
2820            OpenApiFormatClass::Date
2821        );
2822    }
2823
2824    #[test]
2825    fn response_status_and_media_grammar_are_strict() {
2826        for status in ["099", "600", "2xx", "20X", "6XX", "XXX"] {
2827            let value = json!({"openapi":"3.1.0","paths":{"/x":{"get":{"responses":{status:{}}}}}});
2828            assert_eq!(
2829                OpenApiDocument::parse_json(&serde_json::to_vec(&value).unwrap()),
2830                Err(OpenApiReviewError::InvalidOperation)
2831            );
2832        }
2833        for media in [
2834            "application//json",
2835            "/json",
2836            "application/",
2837            "application/json/extra",
2838            "application json",
2839        ] {
2840            let value = json!({"openapi":"3.1.0","paths":{"/x":{"get":{"responses":{"200":{"content":{media:{}}}}}}}});
2841            assert_eq!(
2842                OpenApiDocument::parse_json(&serde_json::to_vec(&value).unwrap()),
2843                Err(OpenApiReviewError::InvalidOperation)
2844            );
2845        }
2846    }
2847
2848    #[test]
2849    fn operation_ids_are_scoped_to_semantic_document_basis() {
2850        let first = parse(&document());
2851        let mut changed = document();
2852        changed["servers"][0]["url"] = json!("https://other.example.test/{version}");
2853        let second = parse(&changed);
2854        assert_ne!(
2855            first.catalog().operations()[0].id(),
2856            second.catalog().operations()[0].id()
2857        );
2858        changed["servers"][0] = json!({"url":"/v1"});
2859        let third = parse(&changed);
2860        assert_ne!(
2861            first.catalog().operations()[0].id(),
2862            third.catalog().operations()[0].id()
2863        );
2864        assert_eq!(
2865            first.catalog().operations()[0].source_document_identity(),
2866            first.semantic_digest()
2867        );
2868    }
2869
2870    #[test]
2871    fn swagger_version_access_is_panic_free() {
2872        let swagger = parse(&json!({"swagger":"2.0"}));
2873        assert_eq!(swagger.version(), None);
2874    }
2875
2876    #[test]
2877    fn source_contract_has_no_transport_or_action_authority() {
2878        let source = include_str!("openapi_review.rs");
2879        for forbidden in [
2880            ["reqwest", "::"].concat(),
2881            ["HttpRequest", "Broker"].concat(),
2882            ["SharedWebRuntime", "Authority"].concat(),
2883            ["Decision", "Executor"].concat(),
2884            ["Runtime", "Budget"].concat(),
2885            ["async", " fn"].concat(),
2886        ] {
2887            assert!(
2888                !source.contains(&forbidden),
2889                "forbidden authority marker: {forbidden}"
2890            );
2891        }
2892    }
2893
2894    #[test]
2895    fn malformed_server_template_braces_and_unsupported_servers_are_exact() {
2896        let origin = Url::parse("https://api.example.test/").unwrap();
2897        for server in [
2898            "https://{tenant.example.test",
2899            "https://tenant}.example.test",
2900            "https://{{tenant}}.example.test",
2901            "https://{outer{inner}}.example.test",
2902            "https://{tenant}}.example.test",
2903        ] {
2904            let value = json!({"openapi":"3.1.0","servers":[{"url":server,"variables":{"tenant":{},"outer":{},"inner":{}}}],"paths":{}});
2905            assert_eq!(
2906                parse_openapi_document(&serde_json::to_vec(&value).unwrap(), &origin),
2907                OpenApiParseOutcome::LimitExceeded
2908            );
2909        }
2910        let value = json!({"openapi":"3.1.0","servers":[{"url":"mailto:security@example.test"}],"paths":{}});
2911        let OpenApiParseOutcome::Complete(document) =
2912            parse_openapi_document(&serde_json::to_vec(&value).unwrap(), &origin)
2913        else {
2914            panic!("expected complete document")
2915        };
2916        assert_eq!(document.servers()[0].kind(), OpenApiServerKind::Unsupported);
2917    }
2918
2919    #[test]
2920    fn catalog_queries_return_deterministic_operations() {
2921        let value = json!({"openapi":"3.1.0","components":{"securitySchemes":{"bearer":{"type":"http","scheme":"bearer"}}},"paths":{
2922            "/upload/{id}":{"post":{"security":[{"bearer":[]}],"parameters":[{"name":"id","in":"path","required":true},{"name":"target","in":"query","schema":{"type":"string","format":"uri"}},{"name":"trace","in":"header"},{"name":"session","in":"cookie"}],"requestBody":{"content":{"multipart/form-data":{}}},"responses":{"200":{"content":{"application/problem+json":{}}}}}},
2923            "/public":{"get":{"security":[],"responses":{"200":{"content":{"text/plain":{}}}}}}
2924        }});
2925        let document = parse(&value);
2926        let catalog = document.catalog();
2927        assert_eq!(catalog.with_any_parameter().len(), 1);
2928        assert_eq!(catalog.with_path_parameter().len(), 1);
2929        assert_eq!(catalog.with_query_parameter().len(), 1);
2930        assert_eq!(catalog.with_header_parameter().len(), 1);
2931        assert_eq!(catalog.with_cookie_parameter().len(), 1);
2932        assert_eq!(catalog.with_url_like_input().len(), 1);
2933        assert_eq!(catalog.with_multipart_request().len(), 1);
2934        assert_eq!(catalog.declaring_explicit_auth().len(), 1);
2935        assert_eq!(catalog.declaring_anonymous_access().len(), 1);
2936        assert_eq!(catalog.with_json_compatible_response().len(), 1);
2937        assert_eq!(
2938            catalog.with_any_parameter()[0].id(),
2939            catalog.with_url_like_input()[0].id()
2940        );
2941    }
2942
2943    #[test]
2944    fn media_parameter_and_security_taxonomies_are_closed_and_typed() {
2945        for (media, expected) in [
2946            ("application/json", OpenApiMediaFamily::Json),
2947            ("application/problem+json", OpenApiMediaFamily::JsonSuffix),
2948            (
2949                "application/x-www-form-urlencoded",
2950                OpenApiMediaFamily::FormUrlEncoded,
2951            ),
2952            ("multipart/form-data", OpenApiMediaFamily::Multipart),
2953            ("application/xml", OpenApiMediaFamily::Xml),
2954            ("text/plain", OpenApiMediaFamily::Text),
2955            ("application/octet-stream", OpenApiMediaFamily::Binary),
2956            ("application/cbor", OpenApiMediaFamily::Unknown),
2957        ] {
2958            assert_eq!(media_family(media), expected);
2959        }
2960
2961        let formats = [
2962            (Some("uri"), OpenApiFormatClass::Uri),
2963            (Some("url"), OpenApiFormatClass::Url),
2964            (Some("hostname"), OpenApiFormatClass::Hostname),
2965            (Some("uuid"), OpenApiFormatClass::Uuid),
2966            (Some("email"), OpenApiFormatClass::Email),
2967            (Some("password"), OpenApiFormatClass::Password),
2968            (Some("binary"), OpenApiFormatClass::Binary),
2969            (Some("byte"), OpenApiFormatClass::Byte),
2970            (Some("date-time"), OpenApiFormatClass::DateTime),
2971            (Some("date"), OpenApiFormatClass::Date),
2972            (Some("future"), OpenApiFormatClass::Other),
2973            (None, OpenApiFormatClass::None),
2974        ];
2975        let parameters = formats
2976            .iter()
2977            .enumerate()
2978            .map(|(index, (format, _))| {
2979                let mut schema = Map::new();
2980                schema.insert("type".to_owned(), json!("string"));
2981                if let Some(format) = format {
2982                    schema.insert("format".to_owned(), json!(format));
2983                }
2984                json!({"name":format!("value-{index}"),"in":"query","schema":schema})
2985            })
2986            .collect::<Vec<_>>();
2987        let value = json!({"openapi":"3.1.0","paths":{"/x":{"get":{"parameters":parameters,"responses":{}}}}});
2988        let document = parse(&value);
2989        let observed = document.catalog().operations()[0]
2990            .parameters()
2991            .iter()
2992            .map(OpenApiParameterMetadata::format_class)
2993            .collect::<BTreeSet<_>>();
2994        assert_eq!(
2995            observed,
2996            formats
2997                .iter()
2998                .map(|(_, expected)| *expected)
2999                .collect::<BTreeSet<_>>()
3000        );
3001
3002        let value = json!({
3003            "openapi":"3.1.0",
3004            "components":{"securitySchemes":{
3005                "api-query":{"type":"apiKey","in":"query"},
3006                "api-header":{"type":"apiKey","in":"header"},
3007                "api-cookie":{"type":"apiKey","in":"cookie"},
3008                "api-unknown":{"type":"apiKey","in":"future"},
3009                "basic":{"type":"http","scheme":"basic"},
3010                "bearer":{"type":"http","scheme":"bearer"},
3011                "http-other":{"type":"http","scheme":"digest"},
3012                "mtls":{"type":"mutualTLS"},
3013                "oauth":{"type":"oauth2"},
3014                "oidc":{"type":"openIdConnect"},
3015                "future":{"type":"future"}
3016            }},
3017            "security":[{
3018                "api-query":[],"api-header":[],"api-cookie":[],"api-unknown":[],
3019                "basic":[],"bearer":[],"http-other":[],"mtls":[],"oauth":[],
3020                "oidc":[],"future":[]
3021            }],
3022            "paths":{}
3023        });
3024        let document = parse(&value);
3025        assert_eq!(
3026            document.security_schemes(),
3027            &[
3028                OpenApiSecuritySchemeKind::ApiKeyQuery,
3029                OpenApiSecuritySchemeKind::ApiKeyHeader,
3030                OpenApiSecuritySchemeKind::ApiKeyCookie,
3031                OpenApiSecuritySchemeKind::HttpBasic,
3032                OpenApiSecuritySchemeKind::HttpBearer,
3033                OpenApiSecuritySchemeKind::HttpOther,
3034                OpenApiSecuritySchemeKind::MutualTls,
3035                OpenApiSecuritySchemeKind::OAuth2,
3036                OpenApiSecuritySchemeKind::OpenIdConnect,
3037                OpenApiSecuritySchemeKind::Unknown,
3038            ]
3039        );
3040        assert_eq!(
3041            document.root_security().scheme_kinds(),
3042            document.security_schemes()
3043        );
3044    }
3045
3046    #[test]
3047    fn catalog_component_limits_fail_closed() {
3048        let parse_error = |value: Value| {
3049            OpenApiDocument::parse_json(&serde_json::to_vec(&value).unwrap()).unwrap_err()
3050        };
3051
3052        let parameters = (0..=MAX_OPENAPI_PARAMETERS_PER_OPERATION)
3053            .map(|index| json!({"name":format!("p-{index}"),"in":"query"}))
3054            .collect::<Vec<_>>();
3055        assert_eq!(
3056            parse_error(
3057                json!({"openapi":"3.1.0","paths":{"/x":{"get":{"parameters":parameters}}}})
3058            ),
3059            OpenApiReviewError::CatalogLimit
3060        );
3061
3062        let media = (0..=MAX_OPENAPI_MEDIA_TYPES_PER_OPERATION)
3063            .map(|index| (format!("application/x-{index}"), json!({})))
3064            .collect::<Map<_, _>>();
3065        assert_eq!(
3066            parse_error(
3067                json!({"openapi":"3.1.0","paths":{"/x":{"post":{"requestBody":{"content":media}}}}})
3068            ),
3069            OpenApiReviewError::CatalogLimit
3070        );
3071
3072        let responses = (100..=100 + MAX_OPENAPI_RESPONSES_PER_OPERATION)
3073            .map(|status| (status.to_string(), json!({})))
3074            .collect::<Map<_, _>>();
3075        assert_eq!(
3076            parse_error(json!({"openapi":"3.1.0","paths":{"/x":{"get":{"responses":responses}}}})),
3077            OpenApiReviewError::CatalogLimit
3078        );
3079
3080        let schemes = (0..=MAX_OPENAPI_SECURITY_REQUIREMENTS)
3081            .map(|index| {
3082                (
3083                    format!("s-{index}"),
3084                    json!({"type":"http","scheme":"bearer"}),
3085                )
3086            })
3087            .collect::<Map<_, _>>();
3088        assert_eq!(
3089            parse_error(
3090                json!({"openapi":"3.1.0","components":{"securitySchemes":schemes},"paths":{}})
3091            ),
3092            OpenApiReviewError::CatalogLimit
3093        );
3094
3095        let servers = (0..=MAX_OPENAPI_SERVERS)
3096            .map(|index| json!({"url":format!("/server-{index}")}))
3097            .collect::<Vec<_>>();
3098        assert_eq!(
3099            parse_error(json!({"openapi":"3.1.0","servers":servers,"paths":{}})),
3100            OpenApiReviewError::CatalogLimit
3101        );
3102
3103        let limits = OpenApiDocumentLimits {
3104            operations: 1,
3105            ..OpenApiDocumentLimits::default()
3106        };
3107        assert_eq!(
3108            OpenApiDocument::parse_json_with_limits(
3109                br#"{"openapi":"3.1.0","paths":{"/x":{"get":{},"post":{}}}}"#,
3110                limits,
3111            )
3112            .unwrap_err(),
3113            OpenApiReviewError::CatalogLimit
3114        );
3115    }
3116
3117    #[test]
3118    fn arbitrary_bounded_bytes_are_deterministic_and_never_panic() {
3119        let origin = Url::parse("https://example.test/").unwrap();
3120        let mut state = 0x9e37_79b9_u32;
3121        for length in (0..=4096).step_by(31) {
3122            let mut bytes = Vec::with_capacity(length);
3123            for _ in 0..length {
3124                state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
3125                bytes.push((state >> 24) as u8);
3126            }
3127            let first = std::panic::catch_unwind(|| parse_openapi_document(&bytes, &origin))
3128                .expect("bounded parser must not panic");
3129            assert_eq!(first, parse_openapi_document(&bytes, &origin));
3130        }
3131    }
3132
3133    #[test]
3134    fn server_resolution_is_conservative_and_normalized_identity_is_material() {
3135        let origin = Url::parse("https://api.example.test/base/document.json").unwrap();
3136        for raw in [
3137            "//evil.example/x",
3138            "  //evil.example/x",
3139            "\\\\evil.example\\x",
3140        ] {
3141            let value = json!({"openapi":"3.1.0","servers":[{"url":raw}],"paths":{}});
3142            let OpenApiParseOutcome::Complete(document) =
3143                parse_openapi_document(&serde_json::to_vec(&value).unwrap(), &origin)
3144            else {
3145                panic!("expected complete")
3146            };
3147            assert_eq!(document.servers()[0].kind(), OpenApiServerKind::CrossOrigin);
3148        }
3149        let relative = json!({"openapi":"3.1.0","servers":[{"url":"v1"}],"paths":{}});
3150        let equivalent = json!({"openapi":"3.1.0","servers":[{"url":"./v1"}],"paths":{}});
3151        assert_eq!(
3152            parse(&relative).semantic_digest(),
3153            parse(&equivalent).semantic_digest()
3154        );
3155        let changed = json!({"openapi":"3.1.0","servers":[{"url":"v2"}],"paths":{}});
3156        assert_ne!(
3157            parse(&relative).semantic_digest(),
3158            parse(&changed).semantic_digest()
3159        );
3160    }
3161
3162    #[test]
3163    fn cumulative_media_limit_ranges_extensions_and_mixed_security_are_exact() {
3164        let request = (0..32)
3165            .map(|index| (format!("application/request-{index}"), json!({})))
3166            .collect::<serde_json::Map<_, _>>();
3167        let response = (0..32)
3168            .map(|index| (format!("application/response-{index}"), json!({})))
3169            .collect::<serde_json::Map<_, _>>();
3170        let accepted = json!({"openapi":"3.1.0","components":{"securitySchemes":{"basic":{"type":"http","scheme":"basic"}}},"security":[{}, {"basic":[]}],"paths":{"x-note":{"safe":true},"/x":{"get":{"operationId":"boundedOperation","parameters":[{"name":"host","in":"query","schema":{"type":"string","format":"hostname"}}],"requestBody":{"content":request},"responses":{"2XX":{"content":response}}}}}});
3171        let document = parse(&accepted);
3172        let operation = &document.catalog().operations()[0];
3173        assert_eq!(document.path_count(), 1);
3174        assert_eq!(
3175            operation.responses()[0].status(),
3176            OpenApiResponseStatus::Range(2)
3177        );
3178        assert!(operation.security().permits_anonymous() && operation.security().declares_auth());
3179        assert!(operation
3180            .candidate_tags()
3181            .contains(&OpenApiCandidateTag::DeclaresAnonymousAccess));
3182        assert!(operation
3183            .candidate_tags()
3184            .contains(&OpenApiCandidateTag::DeclaresSecurity));
3185        assert!(operation
3186            .candidate_tags()
3187            .contains(&OpenApiCandidateTag::SsrfUrlCandidate));
3188        assert_eq!(document.catalog().declaring_anonymous_access().len(), 1);
3189        assert_eq!(document.catalog().declaring_explicit_auth().len(), 1);
3190        for range in ["*/*", "application/*"] {
3191            assert!(normalize_media_type(range).is_ok());
3192        }
3193        let mut too_many = accepted;
3194        too_many["paths"]["/x"]["get"]["responses"]["3XX"] =
3195            json!({"content":{"application/extra":{}}});
3196        assert_eq!(
3197            OpenApiDocument::parse_json(&serde_json::to_vec(&too_many).unwrap()),
3198            Err(OpenApiReviewError::CatalogLimit)
3199        );
3200        assert_eq!(operation.parameters()[0].name_fingerprint().len(), 32);
3201        assert_eq!(
3202            operation.declared_operation_id_fingerprint().unwrap().len(),
3203            32
3204        );
3205    }
3206}