Skip to main content

termivar_scanner/api_observation/
query.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use std::fmt;
3use termivar_core::{EntityId, RelationId};
4
5use crate::{
6    api_observation::{
7        cursor::ApiVisibilityReviewCursor,
8        model::ApiObservationError,
9        review::{project_api_visibility_review, ApiVisibilityReview},
10        DEFAULT_API_VISIBILITY_REVIEW_SCAN_LIMIT, HARD_MAX_API_VISIBILITY_REVIEW_SCAN_LIMIT,
11    },
12    knowledge::{KnowledgeBase, MAX_KNOWLEDGE_RELATION_ID_BYTES},
13};
14
15/// Bounded cursor for one resource-scoped API visibility review page.
16///
17/// The scan limit counts incoming relations inspected, including malformed or
18/// unrelated relations that the projection rejects. This prevents a resource
19/// with many noncanonical edges from forcing an unbounded clone or scan.
20///
21/// Relation cursors are opaque continuation capabilities, not authenticated
22/// pagination tokens. A cursor can identify the last relation inspected even
23/// when that relation was omitted from the review page. Hosts must authorize
24/// access to the resource before accepting a query, use non-secret relation
25/// identifiers, scope a cursor to the same resource, and avoid logging it.
26#[derive(Clone, PartialEq, Eq, Serialize)]
27pub struct ApiVisibilityReviewQuery {
28    after_relation_id: Option<RelationId>,
29    scan_limit: u16,
30}
31
32impl fmt::Debug for ApiVisibilityReviewQuery {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        formatter
35            .debug_struct("ApiVisibilityReviewQuery")
36            .field(
37                "after_relation_id",
38                &self.after_relation_id.as_ref().map(|_| "<redacted>"),
39            )
40            .field("scan_limit", &self.scan_limit)
41            .finish()
42    }
43}
44
45impl ApiVisibilityReviewQuery {
46    /// Creates a query with a positive scan limit under the compiled ceiling.
47    pub fn new(scan_limit: u16) -> Result<Self, ApiObservationError> {
48        if scan_limit == 0 {
49            return Err(ApiObservationError::ZeroReviewScanLimit);
50        }
51        if scan_limit > HARD_MAX_API_VISIBILITY_REVIEW_SCAN_LIMIT {
52            return Err(ApiObservationError::ReviewScanLimitExceeded {
53                actual: scan_limit,
54                maximum: HARD_MAX_API_VISIBILITY_REVIEW_SCAN_LIMIT,
55            });
56        }
57        Ok(Self {
58            after_relation_id: None,
59            scan_limit,
60        })
61    }
62
63    /// Starts after one previously scanned opaque, non-secret relation cursor.
64    ///
65    /// The host must reuse this cursor only for the resource and authorization
66    /// context from which it was returned. Reusing it with another resource is
67    /// not rejected, but has no defined traversal meaning.
68    pub fn after_relation_id(
69        mut self,
70        relation_id: RelationId,
71    ) -> Result<Self, ApiObservationError> {
72        if relation_id.as_str().len() > MAX_KNOWLEDGE_RELATION_ID_BYTES {
73            return Err(ApiObservationError::ReviewCursorTooLong {
74                actual: relation_id.as_str().len(),
75                maximum: MAX_KNOWLEDGE_RELATION_ID_BYTES,
76            });
77        }
78        self.after_relation_id = Some(relation_id);
79        Ok(self)
80    }
81
82    /// Returns the exclusive opaque relation cursor, when this is a later page.
83    pub fn after(&self) -> Option<&RelationId> {
84        self.after_relation_id.as_ref()
85    }
86
87    /// Returns the maximum incoming relations inspected by this page.
88    pub const fn scan_limit(&self) -> u16 {
89        self.scan_limit
90    }
91}
92
93impl Default for ApiVisibilityReviewQuery {
94    fn default() -> Self {
95        Self {
96            after_relation_id: None,
97            scan_limit: DEFAULT_API_VISIBILITY_REVIEW_SCAN_LIMIT,
98        }
99    }
100}
101
102impl<'de> Deserialize<'de> for ApiVisibilityReviewQuery {
103    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
104    where
105        D: Deserializer<'de>,
106    {
107        #[derive(Deserialize)]
108        #[serde(deny_unknown_fields)]
109        struct WireQuery {
110            after_relation_id: Option<RelationId>,
111            scan_limit: u16,
112        }
113
114        let wire = WireQuery::deserialize(deserializer)?;
115        let mut query = Self::new(wire.scan_limit).map_err(serde::de::Error::custom)?;
116        if let Some(relation_id) = wire.after_relation_id {
117            query = query
118                .after_relation_id(relation_id)
119                .map_err(serde::de::Error::custom)?;
120        }
121        Ok(query)
122    }
123}
124
125/// One bounded page of canonical reviews for a logical resource.
126#[derive(Clone, PartialEq, Eq, Serialize)]
127pub struct ApiVisibilityReviewPage {
128    resource_scope: EntityId,
129    reviews: Vec<ApiVisibilityReview>,
130    scanned_relations: u16,
131    next_after_relation_id: Option<RelationId>,
132}
133
134impl fmt::Debug for ApiVisibilityReviewPage {
135    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136        formatter
137            .debug_struct("ApiVisibilityReviewPage")
138            .field("resource_scope", &"<redacted>")
139            .field("reviews", &self.reviews)
140            .field("scanned_relations", &self.scanned_relations)
141            .field(
142                "next_after_relation_id",
143                &self.next_after_relation_id.as_ref().map(|_| "<redacted>"),
144            )
145            .finish()
146    }
147}
148
149impl ApiVisibilityReviewPage {
150    /// Returns the resource whose incoming relations were scanned.
151    pub fn resource_scope(&self) -> &EntityId {
152        &self.resource_scope
153    }
154
155    /// Returns canonical reviews found inside this bounded relation window.
156    pub fn reviews(&self) -> &[ApiVisibilityReview] {
157        &self.reviews
158    }
159
160    /// Returns the number of incoming relations consumed from the scan budget.
161    pub const fn scanned_relations(&self) -> u16 {
162        self.scanned_relations
163    }
164
165    /// Returns the exclusive opaque cursor for the next page when more relations exist.
166    ///
167    /// This may identify an inspected relation that was structurally rejected
168    /// and therefore absent from [`Self::reviews`]. Treat it as a non-secret,
169    /// capability-scoped continuation value rather than domain data.
170    pub fn next_after_relation_id(&self) -> Option<&RelationId> {
171        self.next_after_relation_id.as_ref()
172    }
173
174    /// Derives the resource-bound v2 continuation token for the next page.
175    ///
176    /// The returned token is deterministic and redacted from `Debug` and
177    /// `Display`, but is not signed. A transport may authenticate its serialized
178    /// form before exposing it outside a trusted host boundary.
179    pub fn next_cursor(&self) -> Result<Option<ApiVisibilityReviewCursor>, ApiObservationError> {
180        self.next_after_relation_id
181            .as_ref()
182            .map(|relation_id| {
183                ApiVisibilityReviewCursor::new(&self.resource_scope, relation_id.clone())
184            })
185            .transpose()
186    }
187
188    /// Takes the canonical reviews without cloning them.
189    pub fn into_reviews(self) -> Vec<ApiVisibilityReview> {
190        self.reviews
191    }
192}
193
194/// Projects one bounded review page using a resource-bound v2 cursor.
195///
196/// A cursor is checked against the caller-authorized resource before the
197/// knowledge store is scanned. The legacy [`ApiVisibilityReviewQuery`] and
198/// [`api_visibility_reviews_for_resource`] contracts remain available for
199/// trusted in-process continuation, while this entry point prevents accidental
200/// cross-resource cursor reuse. This cursor is deterministic, not authenticated;
201/// transports may sign or MAC its serialized representation.
202pub fn api_visibility_reviews_for_resource_v2(
203    knowledge: &KnowledgeBase,
204    resource_scope: &EntityId,
205    cursor: Option<&ApiVisibilityReviewCursor>,
206    scan_limit: u16,
207) -> Result<ApiVisibilityReviewPage, ApiObservationError> {
208    let mut query = ApiVisibilityReviewQuery::new(scan_limit)?;
209    if let Some(cursor) = cursor {
210        if !cursor.matches_resource(resource_scope) {
211            return Err(ApiObservationError::ResourceBoundReviewCursorMismatch);
212        }
213        query = query.after_relation_id(cursor.after_relation_id().clone())?;
214    }
215    Ok(api_visibility_reviews_for_resource(
216        knowledge,
217        resource_scope,
218        &query,
219    ))
220}
221
222/// Projects canonical API visibility comparisons associated with one resource.
223///
224/// The query clones at most its explicit relation limit; whether another page
225/// exists is checked against the borrowed relation index without cloning the
226/// look-ahead record. Referenced evidence and hypotheses are also inspected
227/// while borrowed and cloned only after their variable review fields satisfy
228/// compiled byte ceilings. Results are ordered by stable relation identity.
229/// Structurally unrelated or forged-looking incoming relations consume scan
230/// budget but are omitted. These checks are a read-model hygiene boundary, not
231/// cryptographic attestation.
232///
233/// Pagination is not a database snapshot. Concurrent inserts sort according to
234/// their stable relation IDs; a new ID at or before an already consumed cursor
235/// is not returned by later pages. Hosts that require a frozen export must
236/// provide an external snapshot or quiesce writes for that resource.
237pub fn api_visibility_reviews_for_resource(
238    knowledge: &KnowledgeBase,
239    resource_scope: &EntityId,
240    query: &ApiVisibilityReviewQuery,
241) -> ApiVisibilityReviewPage {
242    let scan_limit = usize::from(query.scan_limit());
243    let (relations, has_more) =
244        knowledge.relations_to_page_with_more(resource_scope, query.after(), scan_limit);
245    let scanned_relations =
246        u16::try_from(relations.len()).expect("validated review scan limits always fit in u16");
247    let next_after_relation_id = has_more
248        .then(|| relations.last().map(|relation| relation.id().clone()))
249        .flatten();
250
251    let mut reviews = Vec::new();
252    for relation in relations {
253        if let Some(review) = project_api_visibility_review(knowledge, resource_scope, &relation) {
254            reviews.push(review);
255        }
256    }
257    ApiVisibilityReviewPage {
258        resource_scope: resource_scope.clone(),
259        reviews,
260        scanned_relations,
261        next_after_relation_id,
262    }
263}