1use 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#[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
114pub 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 pub const fn name_fingerprint(&self) -> &[u8; 32] {
481 &self.name_digest
482 }
483
484 #[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 #[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#[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 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 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 ¶meters,
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: ¶meters,
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, ¶meter.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}