Skip to main content

termivar_scanner/api_observation/
cursor.rs

1use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer};
2use sha2::{Digest, Sha256};
3use std::fmt;
4use termivar_core::{EntityId, RelationId};
5
6use crate::api_observation::{
7    model::ApiObservationError, API_VISIBILITY_REVIEW_CURSOR_DOMAIN,
8    API_VISIBILITY_REVIEW_CURSOR_PREFIX, API_VISIBILITY_REVIEW_RESOURCE_DIGEST_HEX_BYTES,
9    MAX_API_VISIBILITY_REVIEW_CURSOR_BYTES,
10};
11use crate::knowledge::MAX_KNOWLEDGE_RELATION_ID_BYTES;
12
13/// Opaque v2 continuation token bound to one resource and relation position.
14///
15/// The token contains a versioned, domain-separated resource digest and the
16/// last scanned relation ID encoded as lowercase hexadecimal bytes. It never
17/// embeds the clear-text resource identifier. The digest is pseudonymous, not
18/// confidential: low-entropy resource IDs remain susceptible to dictionary
19/// attacks. The token is deterministic but is not authenticated or encrypted;
20/// a transport may sign or MAC its serialized form before exposing it outside
21/// a trusted boundary.
22#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct ApiVisibilityReviewCursor {
24    encoded: String,
25    resource_digest: [u8; 32],
26    after_relation_id: RelationId,
27}
28
29impl ApiVisibilityReviewCursor {
30    /// Creates a canonical v2 cursor for one resource and relation position.
31    pub fn new(
32        resource_scope: &EntityId,
33        after_relation_id: RelationId,
34    ) -> Result<Self, ApiObservationError> {
35        let actual = after_relation_id.as_str().len();
36        if actual > MAX_KNOWLEDGE_RELATION_ID_BYTES {
37            return Err(ApiObservationError::ReviewCursorTooLong {
38                actual,
39                maximum: MAX_KNOWLEDGE_RELATION_ID_BYTES,
40            });
41        }
42        let resource_digest = review_cursor_resource_digest(resource_scope);
43        let encoded = format!(
44            "{API_VISIBILITY_REVIEW_CURSOR_PREFIX}{}:{}",
45            encode_cursor_hex(&resource_digest),
46            encode_cursor_hex(after_relation_id.as_str().as_bytes())
47        );
48        debug_assert!(encoded.len() <= MAX_API_VISIBILITY_REVIEW_CURSOR_BYTES);
49        Ok(Self {
50            encoded,
51            resource_digest,
52            after_relation_id,
53        })
54    }
55
56    /// Parses and validates one canonical serialized v2 cursor.
57    pub fn parse(encoded: impl Into<String>) -> Result<Self, ApiObservationError> {
58        let encoded = encoded.into();
59        if encoded.len() > MAX_API_VISIBILITY_REVIEW_CURSOR_BYTES {
60            return Err(ApiObservationError::ResourceBoundReviewCursorTooLong {
61                actual: encoded.len(),
62                maximum: MAX_API_VISIBILITY_REVIEW_CURSOR_BYTES,
63            });
64        }
65        let Some(payload) = encoded.strip_prefix(API_VISIBILITY_REVIEW_CURSOR_PREFIX) else {
66            if encoded.starts_with("venom-api-review-v") {
67                return Err(ApiObservationError::UnsupportedResourceBoundReviewCursorVersion);
68            }
69            return Err(ApiObservationError::InvalidResourceBoundReviewCursor {
70                reason: "cursor prefix is malformed",
71            });
72        };
73        let Some((resource_digest, relation_id)) = payload.split_once(':') else {
74            return Err(ApiObservationError::InvalidResourceBoundReviewCursor {
75                reason: "cursor payload is incomplete",
76            });
77        };
78        if resource_digest.len() != API_VISIBILITY_REVIEW_RESOURCE_DIGEST_HEX_BYTES {
79            return Err(ApiObservationError::InvalidResourceBoundReviewCursor {
80                reason: "resource digest must contain 64 lowercase hexadecimal characters",
81            });
82        }
83        let resource_digest: [u8; 32] =
84            decode_cursor_hex(resource_digest)?
85                .try_into()
86                .map_err(|_| ApiObservationError::InvalidResourceBoundReviewCursor {
87                    reason: "resource digest must contain exactly 32 bytes",
88                })?;
89        let relation_id = decode_cursor_hex(relation_id)?;
90        if relation_id.len() > MAX_KNOWLEDGE_RELATION_ID_BYTES {
91            return Err(ApiObservationError::ReviewCursorTooLong {
92                actual: relation_id.len(),
93                maximum: MAX_KNOWLEDGE_RELATION_ID_BYTES,
94            });
95        }
96        let relation_id = String::from_utf8(relation_id).map_err(|_| {
97            ApiObservationError::InvalidResourceBoundReviewCursor {
98                reason: "relation identifier is not valid UTF-8",
99            }
100        })?;
101        let after_relation_id = RelationId::parse(relation_id).map_err(|_| {
102            ApiObservationError::InvalidResourceBoundReviewCursor {
103                reason: "relation identifier is empty",
104            }
105        })?;
106        Ok(Self {
107            encoded,
108            resource_digest,
109            after_relation_id,
110        })
111    }
112
113    /// Returns the canonical transport representation.
114    ///
115    /// Callers should avoid logging this value and may wrap it in an
116    /// authenticated transport token before returning it to an untrusted peer.
117    pub fn as_str(&self) -> &str {
118        &self.encoded
119    }
120
121    /// Returns this token's stable wire version.
122    pub const fn version(&self) -> u8 {
123        2
124    }
125
126    pub(super) fn matches_resource(&self, resource_scope: &EntityId) -> bool {
127        self.resource_digest == review_cursor_resource_digest(resource_scope)
128    }
129
130    pub(super) fn after_relation_id(&self) -> &RelationId {
131        &self.after_relation_id
132    }
133}
134
135impl fmt::Debug for ApiVisibilityReviewCursor {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        formatter.write_str("ApiVisibilityReviewCursor(<redacted>)")
138    }
139}
140
141impl fmt::Display for ApiVisibilityReviewCursor {
142    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143        formatter.write_str("<redacted>")
144    }
145}
146
147impl Serialize for ApiVisibilityReviewCursor {
148    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
149    where
150        S: Serializer,
151    {
152        serializer.serialize_str(&self.encoded)
153    }
154}
155
156impl<'de> Deserialize<'de> for ApiVisibilityReviewCursor {
157    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
158    where
159        D: Deserializer<'de>,
160    {
161        struct CursorVisitor;
162
163        impl Visitor<'_> for CursorVisitor {
164            type Value = ApiVisibilityReviewCursor;
165
166            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167                formatter.write_str("a bounded resource-bound API visibility review cursor")
168            }
169
170            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
171            where
172                E: serde::de::Error,
173            {
174                ApiVisibilityReviewCursor::parse(value).map_err(E::custom)
175            }
176
177            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
178            where
179                E: serde::de::Error,
180            {
181                ApiVisibilityReviewCursor::parse(value).map_err(E::custom)
182            }
183        }
184
185        deserializer.deserialize_str(CursorVisitor)
186    }
187}
188
189fn review_cursor_resource_digest(resource_scope: &EntityId) -> [u8; 32] {
190    let bytes = resource_scope.as_str().as_bytes();
191    let mut hasher = Sha256::new();
192    hasher.update(API_VISIBILITY_REVIEW_CURSOR_DOMAIN);
193    hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
194    hasher.update(bytes);
195    hasher.finalize().into()
196}
197
198fn encode_cursor_hex(bytes: &[u8]) -> String {
199    const HEX: &[u8; 16] = b"0123456789abcdef";
200    let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
201    for byte in bytes {
202        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
203        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
204    }
205    encoded
206}
207
208fn decode_cursor_hex(value: &str) -> Result<Vec<u8>, ApiObservationError> {
209    if value.is_empty() || !value.len().is_multiple_of(2) {
210        return Err(ApiObservationError::InvalidResourceBoundReviewCursor {
211            reason: "hexadecimal payload must be non-empty and byte-aligned",
212        });
213    }
214    let (pairs, remainder) = value.as_bytes().as_chunks::<2>();
215    debug_assert!(remainder.is_empty());
216    let mut decoded = Vec::with_capacity(value.len() / 2);
217    for pair in pairs {
218        let high = decode_cursor_hex_nibble(pair[0])?;
219        let low = decode_cursor_hex_nibble(pair[1])?;
220        decoded.push((high << 4) | low);
221    }
222    Ok(decoded)
223}
224
225fn decode_cursor_hex_nibble(value: u8) -> Result<u8, ApiObservationError> {
226    match value {
227        b'0'..=b'9' => Ok(value - b'0'),
228        b'a'..=b'f' => Ok(value - b'a' + 10),
229        _ => Err(ApiObservationError::InvalidResourceBoundReviewCursor {
230            reason: "cursor payload must use lowercase hexadecimal",
231        }),
232    }
233}