termivar_scanner/api_observation/
cursor.rs1use 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#[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 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 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 pub fn as_str(&self) -> &str {
118 &self.encoded
119 }
120
121 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}