Skip to main content

termivar_scanner/
rest_review.rs

1//! Pure OpenAPI-to-REST read-only operation selection.
2//!
3//! Catalog knowledge constrains this selector but grants no transport
4//! authority. The selected URL remains crate-private and must still pass the
5//! parent runtime's exact-origin broker policy before dispatch.
6
7use std::fmt;
8
9use serde::Serialize;
10use sha2::{Digest, Sha256};
11use url::Url;
12
13use crate::openapi_review::{
14    OpenApiDocument, OpenApiHttpMethod, OpenApiMediaFamily, OpenApiOperation, OpenApiOperationId,
15    OpenApiParameterLocation, OpenApiResponseStatus, OpenApiServerKind,
16};
17
18/// Stable semantic revision for bounded REST operation selection.
19pub const REST_REVIEW_SELECTION_ALGORITHM: &str = "security.openapi-rest-readonly-selection/v1";
20/// V1 may retain and execute at most one selected operation.
21pub const MAX_REST_REVIEW_OPERATIONS: usize = 1;
22/// Bound for the fully resolved internal request target.
23pub const MAX_REST_REVIEW_TARGET_BYTES: usize = 8 * 1024;
24
25const REST_TARGET_IDENTITY_DOMAIN: &[u8] = b"security.openapi-rest-readonly-target.v1\0";
26
27/// Documented response-media confidence used only for deterministic ranking.
28#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum RestDocumentedResponseClass {
32    /// At least one explicit 2xx response offers JSON or `+json`.
33    JsonCompatible,
34    /// The success response media is absent or otherwise unspecified.
35    Unknown,
36}
37
38/// Result of filtering one replay-stable OpenAPI catalog.
39#[derive(Clone, Eq, PartialEq)]
40#[non_exhaustive]
41pub enum RestOperationSelectionOutcome {
42    /// Exactly one deterministic operation was selected from the eligible set.
43    Selected(RestOperationSelection),
44    /// No operation met every V1 safety condition.
45    NoEligibleOperation,
46}
47
48impl fmt::Debug for RestOperationSelectionOutcome {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::Selected(selection) => {
52                formatter.debug_tuple("Selected").field(selection).finish()
53            },
54            Self::NoEligibleOperation => formatter.write_str("NoEligibleOperation"),
55        }
56    }
57}
58
59/// One bounded, anonymous, bodyless REST GET selected from an OpenAPI catalog.
60///
61/// Debug deliberately omits the URL and its path. The URL is not serializable
62/// and is available only to the in-crate runtime, which must independently
63/// enforce exact-origin authority.
64#[derive(Clone, Eq, PartialEq)]
65pub struct RestOperationSelection {
66    operation_id: OpenApiOperationId,
67    execution_url: Url,
68    target_identity: String,
69    documented_response: RestDocumentedResponseClass,
70    deprecated: bool,
71    eligible_operation_count: u32,
72}
73
74impl RestOperationSelection {
75    /// Returns the stable, digest-based catalog operation identity.
76    pub const fn operation_id(&self) -> &OpenApiOperationId {
77        &self.operation_id
78    }
79
80    /// Returns the response-media class used in deterministic ranking.
81    pub const fn documented_response(&self) -> RestDocumentedResponseClass {
82        self.documented_response
83    }
84
85    /// Returns whether the document marked the selected operation deprecated.
86    pub const fn deprecated(&self) -> bool {
87        self.deprecated
88    }
89
90    /// Returns the number of operations that passed every V1 safety filter.
91    pub const fn eligible_operation_count(&self) -> u32 {
92        self.eligible_operation_count
93    }
94
95    /// Returns the redacted stable scope identity used by replay comparison.
96    pub fn target_identity(&self) -> &str {
97        &self.target_identity
98    }
99
100    /// Returns the request target only to the parent-owned in-crate runtime.
101    pub(crate) const fn execution_url(&self) -> &Url {
102        &self.execution_url
103    }
104}
105
106impl fmt::Debug for RestOperationSelection {
107    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108        formatter
109            .debug_struct("RestOperationSelection")
110            .field("operation_id", &self.operation_id)
111            .field("execution_url", &"<exact-origin-redacted>")
112            .field("target_identity", &self.target_identity)
113            .field("documented_response", &self.documented_response)
114            .field("deprecated", &self.deprecated)
115            .field("eligible_operation_count", &self.eligible_operation_count)
116            .finish()
117    }
118}
119
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121enum RestOperationIneligibility {
122    Method,
123    RequiredParameter(OpenApiParameterLocation),
124    RequestBody,
125    Authentication,
126    Server,
127    ResponseMedia,
128    Target,
129}
130
131#[derive(Clone)]
132struct EligibleOperation {
133    selection: RestOperationSelection,
134    server_rank: u8,
135    path_bytes: usize,
136}
137
138/// Selects at most one operation without materializing parameters, examples,
139/// defaults, request bodies, credentials, cookies, or network actions.
140pub fn select_rest_operation(
141    document: &OpenApiDocument,
142    document_url: &Url,
143) -> RestOperationSelectionOutcome {
144    let mut eligible = document
145        .catalog()
146        .operations()
147        .iter()
148        .filter_map(|operation| eligible_operation(document_url, operation).ok())
149        .collect::<Vec<_>>();
150    let eligible_operation_count = u32::try_from(eligible.len()).unwrap_or(u32::MAX);
151    eligible.sort_by(|left, right| {
152        left.server_rank
153            .cmp(&right.server_rank)
154            .then(
155                left.selection
156                    .documented_response
157                    .cmp(&right.selection.documented_response),
158            )
159            .then(left.selection.deprecated.cmp(&right.selection.deprecated))
160            .then(left.path_bytes.cmp(&right.path_bytes))
161            .then(
162                left.selection
163                    .operation_id
164                    .cmp(&right.selection.operation_id),
165            )
166    });
167    match eligible.into_iter().next() {
168        Some(mut selected) => {
169            selected.selection.eligible_operation_count = eligible_operation_count;
170            RestOperationSelectionOutcome::Selected(selected.selection)
171        },
172        None => RestOperationSelectionOutcome::NoEligibleOperation,
173    }
174}
175
176fn eligible_operation(
177    document_url: &Url,
178    operation: &OpenApiOperation,
179) -> Result<EligibleOperation, RestOperationIneligibility> {
180    if operation.method() != OpenApiHttpMethod::Get {
181        return Err(RestOperationIneligibility::Method);
182    }
183    if let Some(parameter) = operation
184        .parameters()
185        .iter()
186        .find(|parameter| parameter.required())
187    {
188        return Err(RestOperationIneligibility::RequiredParameter(
189            parameter.location(),
190        ));
191    }
192    if operation.request_body_declared() {
193        return Err(RestOperationIneligibility::RequestBody);
194    }
195    if !operation.security().permits_anonymous() {
196        return Err(RestOperationIneligibility::Authentication);
197    }
198    let (execution_url, server_rank) = resolve_operation_target(document_url, operation)?;
199    let documented_response = documented_response_class(operation)?;
200    let target_identity = rest_target_identity(operation.id(), &execution_url);
201    Ok(EligibleOperation {
202        selection: RestOperationSelection {
203            operation_id: operation.id().clone(),
204            execution_url,
205            target_identity,
206            documented_response,
207            deprecated: operation.deprecated(),
208            eligible_operation_count: 0,
209        },
210        server_rank,
211        path_bytes: operation.path().len(),
212    })
213}
214
215fn documented_response_class(
216    operation: &OpenApiOperation,
217) -> Result<RestDocumentedResponseClass, RestOperationIneligibility> {
218    let relevant = operation
219        .responses()
220        .iter()
221        .filter(|response| {
222            matches!(
223                response.status(),
224                OpenApiResponseStatus::Exact(200..=299) | OpenApiResponseStatus::Range(2)
225            )
226        })
227        .collect::<Vec<_>>();
228    if relevant.iter().any(|response| {
229        response.media_families().iter().any(|family| {
230            matches!(
231                family,
232                OpenApiMediaFamily::Json | OpenApiMediaFamily::JsonSuffix
233            )
234        })
235    }) {
236        return Ok(RestDocumentedResponseClass::JsonCompatible);
237    }
238    if relevant.is_empty()
239        || relevant
240            .iter()
241            .any(|response| response.media_families().is_empty())
242    {
243        return Ok(RestDocumentedResponseClass::Unknown);
244    }
245    Err(RestOperationIneligibility::ResponseMedia)
246}
247
248fn resolve_operation_target(
249    document_url: &Url,
250    operation: &OpenApiOperation,
251) -> Result<(Url, u8), RestOperationIneligibility> {
252    if !document_url_has_safe_origin(document_url)
253        || operation.path().contains(['{', '}'])
254        || operation.servers().len() > 1
255    {
256        return Err(RestOperationIneligibility::Server);
257    }
258    let (mut base, server_rank) = match operation.servers() {
259        [] => {
260            let mut root = document_url.clone();
261            root.set_path("/");
262            root.set_query(None);
263            root.set_fragment(None);
264            (root, 0)
265        },
266        [server]
267            if matches!(server.kind(), OpenApiServerKind::ExactOrigin)
268                && server.execution_base().is_some() =>
269        {
270            (
271                server
272                    .execution_base()
273                    .expect("guarded server base")
274                    .clone(),
275                0,
276            )
277        },
278        [server]
279            if matches!(server.kind(), OpenApiServerKind::Relative)
280                && server.execution_base().is_some() =>
281        {
282            (
283                server
284                    .execution_base()
285                    .expect("guarded server base")
286                    .clone(),
287                1,
288            )
289        },
290        [_] => return Err(RestOperationIneligibility::Server),
291        _ => unreachable!("server count was bounded above"),
292    };
293    let base_path = base.path().trim_end_matches('/');
294    let path = if base_path.is_empty() {
295        operation.path().to_owned()
296    } else {
297        format!("{base_path}{}", operation.path())
298    };
299    if !safe_execution_path(&path) {
300        return Err(RestOperationIneligibility::Target);
301    }
302    base.set_path(&path);
303    base.set_query(None);
304    base.set_fragment(None);
305    if base.as_str().len() > MAX_REST_REVIEW_TARGET_BYTES
306        || !same_origin(document_url, &base)
307        || !document_url_has_safe_origin(&base)
308    {
309        return Err(RestOperationIneligibility::Target);
310    }
311    Ok((base, server_rank))
312}
313
314fn document_url_has_safe_origin(url: &Url) -> bool {
315    matches!(url.scheme(), "http" | "https")
316        && url.username().is_empty()
317        && url.password().is_none()
318        && url.query().is_none()
319        && url.fragment().is_none()
320}
321
322fn safe_execution_path(path: &str) -> bool {
323    path.starts_with('/')
324        && !path.contains(['?', '#', '%', '\\', '\r', '\n', '\0'])
325        && !path.contains("//")
326        && !path.split('/').any(|segment| matches!(segment, "." | ".."))
327}
328
329fn same_origin(left: &Url, right: &Url) -> bool {
330    left.scheme() == right.scheme()
331        && left.host_str() == right.host_str()
332        && left.port_or_known_default() == right.port_or_known_default()
333}
334
335fn rest_target_identity(operation: &OpenApiOperationId, target: &Url) -> String {
336    let mut digest = Sha256::new();
337    update_framed(&mut digest, REST_TARGET_IDENTITY_DOMAIN);
338    update_framed(&mut digest, REST_REVIEW_SELECTION_ALGORITHM.as_bytes());
339    update_framed(&mut digest, operation.as_str().as_bytes());
340    update_framed(&mut digest, target.as_str().as_bytes());
341    format!("rest-readonly-target-sha256:{:x}", digest.finalize())
342}
343
344fn update_framed(digest: &mut Sha256, value: &[u8]) {
345    digest.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
346    digest.update(value);
347}
348
349#[cfg(test)]
350mod tests {
351    use serde_json::{json, Value};
352
353    use super::*;
354    use crate::openapi_review::{parse_openapi_document, OpenApiCandidateTag, OpenApiParseOutcome};
355
356    fn document_at(value: Value, document_url: &Url) -> OpenApiDocument {
357        match parse_openapi_document(&serde_json::to_vec(&value).unwrap(), document_url) {
358            OpenApiParseOutcome::Complete(document) => document,
359            outcome => panic!("expected complete OpenAPI document, got {outcome:?}"),
360        }
361    }
362
363    fn operation(path: &str, method: &str, body: Value) -> Value {
364        json!({
365            "openapi": "3.1.0",
366            "info": {"title": "Fixture", "version": "1"},
367            "paths": {(path): {(method): body}}
368        })
369    }
370
371    fn selected(value: Value, document_url: &Url) -> RestOperationSelection {
372        let document = document_at(value, document_url);
373        match select_rest_operation(&document, document_url) {
374            RestOperationSelectionOutcome::Selected(selection) => selection,
375            RestOperationSelectionOutcome::NoEligibleOperation => panic!("expected selection"),
376        }
377    }
378
379    fn assert_none(value: Value, document_url: &Url) {
380        let document = document_at(value, document_url);
381        assert_eq!(
382            select_rest_operation(&document, document_url),
383            RestOperationSelectionOutcome::NoEligibleOperation
384        );
385    }
386
387    #[test]
388    fn selects_one_anonymous_bodyless_get_with_json_response() {
389        let url = Url::parse("https://example.test/docs/openapi.json").unwrap();
390        let selection = selected(
391            operation(
392                "/health",
393                "get",
394                json!({"responses":{"200":{"content":{"application/json":{}}}}}),
395            ),
396            &url,
397        );
398        assert_eq!(
399            selection.execution_url().as_str(),
400            "https://example.test/health"
401        );
402        assert_eq!(selection.eligible_operation_count(), 1);
403        assert_eq!(
404            selection.documented_response(),
405            RestDocumentedResponseClass::JsonCompatible
406        );
407        assert!(!selection.deprecated());
408        assert!(selection
409            .operation_id()
410            .as_str()
411            .starts_with("openapi-operation-sha256:"));
412        assert!(selection
413            .target_identity()
414            .starts_with("rest-readonly-target-sha256:"));
415    }
416
417    #[test]
418    fn every_required_parameter_location_is_ineligible() {
419        let url = Url::parse("https://example.test/openapi.json").unwrap();
420        for (location, path) in [
421            ("path", "/items/{id}"),
422            ("query", "/items"),
423            ("header", "/items"),
424            ("cookie", "/items"),
425        ] {
426            assert_none(
427                operation(
428                    path,
429                    "get",
430                    json!({
431                        "parameters":[{"name":"id","in":location,"required":true,"schema":{"type":"string"}}],
432                        "responses":{"200":{"content":{"application/json":{}}}}
433                    }),
434                ),
435                &url,
436            );
437        }
438    }
439
440    #[test]
441    fn empty_request_body_is_still_declared_and_changes_identity() {
442        let url = Url::parse("https://example.test/openapi.json").unwrap();
443        let without = document_at(
444            operation("/items", "get", json!({"responses":{"200":{}}})),
445            &url,
446        );
447        let with = document_at(
448            operation(
449                "/items",
450                "get",
451                json!({"requestBody":{},"responses":{"200":{}}}),
452            ),
453            &url,
454        );
455        assert!(!without.catalog().operations()[0].request_body_declared());
456        assert!(with.catalog().operations()[0].request_body_declared());
457        assert!(with.catalog().operations()[0]
458            .candidate_tags()
459            .contains(&OpenApiCandidateTag::BodyBearing));
460        assert_ne!(without.semantic_digest(), with.semantic_digest());
461        assert_eq!(
462            select_rest_operation(&with, &url),
463            RestOperationSelectionOutcome::NoEligibleOperation
464        );
465    }
466
467    #[test]
468    fn required_security_is_ineligible_but_explicit_anonymous_is_eligible() {
469        let url = Url::parse("https://example.test/openapi.json").unwrap();
470        for (name, scheme) in [
471            ("bearer", json!({"type":"http","scheme":"bearer"})),
472            ("key", json!({"type":"apiKey","in":"header","name":"X-Key"})),
473            ("oauth", json!({"type":"oauth2","flows":{}})),
474        ] {
475            let value = json!({
476                "openapi":"3.1.0",
477                "info":{"title":"Fixture","version":"1"},
478                "components":{"securitySchemes":{(name):scheme}},
479                "security":[{(name):[]}],
480                "paths":{"/health":{"get":{"responses":{"200":{}}}}}
481            });
482            assert_none(value, &url);
483        }
484        let anonymous = json!({
485            "openapi":"3.1.0",
486            "info":{"title":"Fixture","version":"1"},
487            "security":[{"unknown":[]}],
488            "paths":{"/health":{"get":{"security":[],"responses":{"200":{}}}}}
489        });
490        assert_eq!(selected(anonymous, &url).eligible_operation_count(), 1);
491    }
492
493    #[test]
494    fn unsafe_or_ambiguous_servers_are_ineligible() {
495        let url = Url::parse("https://example.test/docs/openapi.json").unwrap();
496        for servers in [
497            json!([{"url":"https://elsewhere.invalid"}]),
498            json!([{"url":"https://{tenant}.example.test","variables":{"tenant":{}}}]),
499            json!([{"url":"/v1"},{"url":"/v2"}]),
500            json!([{"url":"https://user:secret@example.test/api"}]),
501            json!([{"url":"/api?variant=1"}]),
502            json!([{"url":"/api#fragment"}]),
503        ] {
504            let value = json!({
505                "openapi":"3.1.0",
506                "info":{"title":"Fixture","version":"1"},
507                "servers":servers,
508                "paths":{"/health":{"get":{"responses":{"200":{}}}}}
509            });
510            assert_none(value, &url);
511        }
512    }
513
514    #[test]
515    fn relative_server_base_is_concatenated_without_losing_its_path() {
516        let url = Url::parse("https://example.test/docs/openapi.json").unwrap();
517        let value = json!({
518            "openapi":"3.1.0",
519            "info":{"title":"Fixture","version":"1"},
520            "servers":[{"url":"/api/v1"}],
521            "paths":{"/health":{"get":{"responses":{"200":{}}}}}
522        });
523        let selection = selected(value, &url);
524        assert_eq!(
525            selection.execution_url().as_str(),
526            "https://example.test/api/v1/health"
527        );
528    }
529
530    #[test]
531    fn post_and_known_non_json_response_are_ineligible() {
532        let url = Url::parse("https://example.test/openapi.json").unwrap();
533        assert_none(
534            operation("/items", "post", json!({"responses":{"200":{}}})),
535            &url,
536        );
537        assert_none(
538            operation(
539                "/items",
540                "get",
541                json!({"responses":{"200":{"content":{"text/html":{}}}}}),
542            ),
543            &url,
544        );
545    }
546
547    #[test]
548    fn ranking_prefers_json_then_non_deprecated_then_shortest_path() {
549        let url = Url::parse("https://example.test/openapi.json").unwrap();
550        let value = json!({
551            "openapi":"3.1.0",
552            "info":{"title":"Fixture","version":"1"},
553            "paths":{
554                "/unknown":{"get":{"responses":{"200":{}}}},
555                "/deprecated":{"get":{"deprecated":true,"responses":{"200":{"content":{"application/json":{}}}}}},
556                "/longer/path":{"get":{"responses":{"200":{"content":{"application/json":{}}}}}},
557                "/ok":{"get":{"responses":{"200":{"content":{"application/json":{}}}}}}
558            }
559        });
560        let first = selected(value.clone(), &url);
561        let second = selected(value, &url);
562        assert_eq!(first, second);
563        assert_eq!(first.execution_url().path(), "/ok");
564        assert_eq!(first.eligible_operation_count(), 4);
565    }
566
567    #[test]
568    fn unknown_response_media_is_a_lower_ranked_safe_observation() {
569        let url = Url::parse("https://example.test/openapi.json").unwrap();
570        let selection = selected(
571            operation("/health", "get", json!({"responses":{"200":{}}})),
572            &url,
573        );
574        assert_eq!(
575            selection.documented_response(),
576            RestDocumentedResponseClass::Unknown
577        );
578    }
579
580    #[test]
581    fn default_response_metadata_never_outranks_explicit_json_success() {
582        let url = Url::parse("https://example.test/openapi.json").unwrap();
583        let value = json!({
584            "openapi":"3.1.0",
585            "info":{"title":"Fixture","version":"1"},
586            "paths":{
587                "/default":{"get":{"responses":{"default":{"content":{"application/json":{}}}}}},
588                "/explicit":{"get":{"responses":{"200":{"content":{"application/json":{}}}}}}
589            }
590        });
591        let selection = selected(value, &url);
592        assert_eq!(selection.execution_url().path(), "/explicit");
593        assert_eq!(
594            selection.documented_response(),
595            RestDocumentedResponseClass::JsonCompatible
596        );
597    }
598
599    #[test]
600    fn optional_examples_and_defaults_are_never_materialized_into_the_target() {
601        let url = Url::parse("https://example.test/openapi.json").unwrap();
602        let value = operation(
603            "/items",
604            "get",
605            json!({
606                "parameters":[{
607                    "name":"filter",
608                    "in":"query",
609                    "required":false,
610                    "example":"MUST-NOT-BE-MATERIALIZED",
611                    "schema":{"type":"string","default":"MUST-NOT-BE-MATERIALIZED"}
612                }],
613                "responses":{"200":{"content":{"application/json":{}}}}
614            }),
615        );
616        let selection = selected(value, &url);
617        assert_eq!(
618            selection.execution_url().as_str(),
619            "https://example.test/items"
620        );
621        assert!(selection.execution_url().query().is_none());
622        assert!(!format!("{selection:?}").contains("MUST-NOT-BE-MATERIALIZED"));
623    }
624
625    #[test]
626    fn debug_redacts_execution_url_and_path() {
627        let url = Url::parse("https://example.test/openapi.json").unwrap();
628        let selection = selected(
629            operation("/private/sentinel", "get", json!({"responses":{"200":{}}})),
630            &url,
631        );
632        let debug = format!("{selection:?}");
633        assert!(!debug.contains("example.test"));
634        assert!(!debug.contains("private"));
635        assert!(!debug.contains("sentinel"));
636
637        let server_document = document_at(
638            json!({
639                "openapi":"3.1.0",
640                "info":{"title":"Fixture","version":"1"},
641                "servers":[{"url":"/private/base"}],
642                "paths":{"/health":{"get":{"responses":{"200":{}}}}}
643            }),
644            &url,
645        );
646        let server_debug = format!("{:?}", server_document.servers()[0]);
647        assert!(!server_debug.contains("/private/base"));
648        assert!(!server_debug.contains("example.test"));
649    }
650
651    #[test]
652    fn execution_path_rejects_every_encoded_or_control_ambiguous_form() {
653        assert!(safe_execution_path("/plain/status"));
654        for path in [
655            "/encoded%2fsegment",
656            "/double%252e%252e/admin",
657            "/control%00byte",
658            "/query?value=1",
659            "/fragment#part",
660            "/dot/../segment",
661            "/double//segment",
662            "/backslash\\segment",
663        ] {
664            assert!(!safe_execution_path(path), "accepted unsafe path {path}");
665        }
666    }
667}