1use std::collections::BTreeSet;
20use std::fmt;
21
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24use super::metrics_v2::{KeyId, MAX_COUNT, SignatureHex, TimeStamp, VaultId};
25use super::metrics_v2::{closed_string, schema_const};
26use crate::ValidationError;
27
28pub const RECEIPTS_V2_SCHEMA: &str = "leanctx.hive.receipts/v2";
29pub const RECEIPTS_V2_ACK_SCHEMA: &str = "leanctx.hive.receipts-ack/v2";
30pub const ENGINE_RECEIPT_V2_SCHEMA: &str = "leanctx.instance-receipt/v2";
31pub const RECEIPTS_DOMAIN: &str = "leanctx.hive.receipts.v2\n";
33pub const RECEIPTS_ROUTE: &str = "/receipts";
35pub const RECEIPT_ENGINE_ID: &str = "lean-ctx-local";
37pub const RECEIPT_CAPABILITY_ID: &str = "capability://leanctx/context-optimization";
39pub const HMAC_PREFIX: &str = "hmac-sha256:";
41
42pub const MAX_RECEIPTS_V2: usize = 256;
43pub const MAX_RECEIPTS_BODY_BYTES: usize = 1 << 20;
44pub const MAX_MEASUREMENTS: usize = 32;
45pub const MAX_SOURCE_COUNT: u16 = 256;
46
47fn is_lower_hex(value: &str) -> bool {
48 value
49 .bytes()
50 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
51}
52
53fn is_hmac(value: &str) -> bool {
54 value
55 .strip_prefix(HMAC_PREFIX)
56 .is_some_and(|hex| hex.len() == 64 && is_lower_hex(hex))
57}
58
59fn is_sha256(value: &str) -> bool {
60 value
61 .strip_prefix("sha256:")
62 .is_some_and(|hex| hex.len() == 64 && is_lower_hex(hex))
63}
64
65fn is_receipt_id(value: &str) -> bool {
66 value.strip_prefix("rcpt_").is_some_and(|rest| {
67 (1..=64).contains(&rest.len())
68 && rest
69 .bytes()
70 .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase())
71 })
72}
73
74fn is_invocation_id(value: &str) -> bool {
75 (32..=64).contains(&value.len()) && is_lower_hex(value)
76}
77
78fn is_semver(value: &str) -> bool {
80 let (core, pre) = match value.split_once('-') {
81 Some((core, pre)) => (core, Some(pre)),
82 None => (value, None),
83 };
84 let parts: Vec<&str> = core.split('.').collect();
85 let digits =
86 |p: &str, max: usize| (1..=max).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_digit());
87 parts.len() == 3
88 && digits(parts[0], 4)
89 && digits(parts[1], 4)
90 && digits(parts[2], 6)
91 && pre.is_none_or(|p| {
92 (1..=32).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.')
93 })
94}
95
96fn is_measurement_name(value: &str) -> bool {
98 let shape = (1..=64).contains(&value.len())
99 && value
100 .bytes()
101 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'));
102 let mut run = 0usize;
103 let token_like = value.bytes().any(|b| {
104 run = if b.is_ascii_alphanumeric() {
105 run + 1
106 } else {
107 0
108 };
109 run >= 24
110 });
111 shape && !token_like
112}
113
114closed_string!(
115 ReceiptHmac, is_hmac, "receipt hmac"
117);
118closed_string!(
119 Sha256Ref, is_sha256, "sha256 digest"
121);
122closed_string!(
123 ReceiptIdV2, is_receipt_id, "receipt id"
125);
126closed_string!(
127 InvocationIdV2, is_invocation_id, "invocation id"
129);
130closed_string!(
131 ReceiptSemver, is_semver, "version"
133);
134closed_string!(
135 MeasurementName, is_measurement_name, "measurement name"
137);
138
139schema_const!(ReceiptsSchema, RECEIPTS_V2_SCHEMA);
140schema_const!(ReceiptsAckSchema, RECEIPTS_V2_ACK_SCHEMA);
141schema_const!(EngineReceiptSchema, ENGINE_RECEIPT_V2_SCHEMA);
142schema_const!(EngineIdConst, RECEIPT_ENGINE_ID);
143schema_const!(CapabilityIdConst, RECEIPT_CAPABILITY_ID);
144schema_const!(EngineKindConst, "instance");
145
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
148pub struct SchemaVersionOne;
149
150impl Serialize for SchemaVersionOne {
151 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
152 serializer.serialize_u8(1)
153 }
154}
155
156impl<'de> Deserialize<'de> for SchemaVersionOne {
157 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
158 (u64::deserialize(deserializer)? == 1)
159 .then_some(Self)
160 .ok_or_else(|| serde::de::Error::custom("schema_version must be 1"))
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum MeasurementUnit {
167 Tokens,
168 Bytes,
169 Ms,
170 Count,
171 Lines,
172 RatioPpm,
173}
174
175impl MeasurementUnit {
176 pub fn of(unit: &str) -> Option<Self> {
178 Some(match unit {
179 "tokens" => Self::Tokens,
180 "bytes" => Self::Bytes,
181 "ms" => Self::Ms,
182 "count" => Self::Count,
183 "lines" => Self::Lines,
184 "ratio_ppm" => Self::RatioPpm,
185 _ => return None,
186 })
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum Classification {
193 Measured,
194 Estimated,
195 Unavailable,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum AdmissionDecision {
201 Admitted,
202 Rejected,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum ObservationStatus {
208 Succeeded,
209 Degraded,
210 Rejected,
211 Failed,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum FailureCode {
217 PolicyRejected,
218 SourceUnavailable,
219 SourceIntegrityMismatch,
220 ResourceLimit,
221 UnsupportedOperation,
222 Internal,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct ReceiptEngineV2 {
228 pub instance_id: EngineIdConst,
229 pub instance_version: ReceiptSemver,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct ReceiptOperationV2 {
235 pub capability_id: CapabilityIdConst,
236 pub capability_version: ReceiptSemver,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct PolicyAdmissionV2 {
242 pub decision: AdmissionDecision,
243 pub policy_digest: Option<Sha256Ref>,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct ReceiptInvocationV2 {
250 pub schema_version: SchemaVersionOne,
251 pub invocation_id: InvocationIdV2,
252 pub instance: ReceiptEngineV2,
253 pub operation: ReceiptOperationV2,
254 pub input_hmac: ReceiptHmac,
255 pub source_count: u16,
257 pub policy_admission: PolicyAdmissionV2,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct MeasurementV2 {
263 pub name: MeasurementName,
264 pub unit: MeasurementUnit,
265 pub classification: Classification,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub value: Option<u64>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(deny_unknown_fields)]
272pub struct FailureV2 {
273 pub code: FailureCode,
274 pub retryable_by_host: bool,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(deny_unknown_fields)]
279pub struct ReceiptObservationV2 {
280 pub schema_version: SchemaVersionOne,
281 pub invocation_id: InvocationIdV2,
282 pub status: ObservationStatus,
283 pub output_hmac: Option<ReceiptHmac>,
284 pub source_count: u16,
286 pub measurements: Vec<MeasurementV2>,
287 pub failure: Option<FailureV2>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(deny_unknown_fields)]
293pub struct EngineReceiptV2 {
294 pub schema: EngineReceiptSchema,
295 pub invocation: ReceiptInvocationV2,
296 pub observation: ReceiptObservationV2,
297}
298
299impl EngineReceiptV2 {
300 pub fn validate(&self) -> Result<(), ValidationError> {
301 let (i, o) = (&self.invocation, &self.observation);
302 if i.invocation_id != o.invocation_id {
303 return Err(ValidationError::new("receipt: invocation_id differs"));
304 }
305 if !(1..=MAX_SOURCE_COUNT).contains(&i.source_count) || o.source_count > MAX_SOURCE_COUNT {
306 return Err(ValidationError::new("receipt: source_count out of range"));
307 }
308 if o.measurements.len() > MAX_MEASUREMENTS
309 || o.measurements
310 .iter()
311 .any(|m| m.value.is_some_and(|v| v > MAX_COUNT))
312 {
313 return Err(ValidationError::new("receipt: measurements"));
314 }
315 Ok(())
316 }
317
318 pub fn digest(&self) -> Result<Sha256Ref, ValidationError> {
320 use sha2::Digest as _;
321 let value = serde_json::to_value(self)
322 .map_err(|e| ValidationError::new(format!("receipt: encode: {e}")))?;
323 let bytes = super::canonical_json(&value)?;
324 let hex: String = sha2::Sha256::digest(&bytes)
325 .iter()
326 .map(|b| format!("{b:02x}"))
327 .collect();
328 Sha256Ref::new(&format!("sha256:{hex}"))
329 .ok_or_else(|| ValidationError::new("receipt: digest"))
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(deny_unknown_fields)]
335pub struct ReceiptItemV2 {
336 pub receipt_id: ReceiptIdV2,
337 pub kind: EngineKindConst,
338 pub vault_id: VaultId,
339 pub created_at: TimeStamp,
340 pub digest: Sha256Ref,
341 pub document: EngineReceiptV2,
342}
343
344impl ReceiptItemV2 {
345 pub fn new(
347 receipt_id: ReceiptIdV2,
348 vault_id: VaultId,
349 created_at: TimeStamp,
350 document: EngineReceiptV2,
351 ) -> Result<Self, ValidationError> {
352 document.validate()?;
353 Ok(Self {
354 receipt_id,
355 kind: EngineKindConst,
356 vault_id,
357 created_at,
358 digest: document.digest()?,
359 document,
360 })
361 }
362
363 pub fn validate(&self) -> Result<(), ValidationError> {
364 self.document.validate()?;
365 if self.document.digest()? != self.digest {
366 return Err(ValidationError::new(
367 "receipt: digest does not bind the document",
368 ));
369 }
370 Ok(())
371 }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(deny_unknown_fields)]
377pub struct ReceiptsBodyV2 {
378 pub schema: ReceiptsSchema,
379 pub receipts: Vec<ReceiptItemV2>,
380}
381
382impl ReceiptsBodyV2 {
383 pub fn validate(&self) -> Result<(), ValidationError> {
384 if self.receipts.is_empty() || self.receipts.len() > MAX_RECEIPTS_V2 {
385 return Err(ValidationError::new("receipts: 1-256 per batch"));
386 }
387 let mut ids = BTreeSet::new();
388 if !self
389 .receipts
390 .iter()
391 .all(|r| ids.insert(r.receipt_id.as_str()))
392 {
393 return Err(ValidationError::new("receipts: duplicate receipt_id"));
394 }
395 self.receipts.iter().try_for_each(ReceiptItemV2::validate)
396 }
397
398 pub fn signing_input(&self, key_id: &KeyId) -> Result<Vec<u8>, ValidationError> {
401 let mut value = serde_json::to_value(self)
402 .map_err(|e| ValidationError::new(format!("receipts: encode: {e}")))?;
403 value["key_id"] = serde_json::Value::String(key_id.as_str().to_string());
404 super::signing_input(RECEIPTS_DOMAIN, &value)
405 }
406
407 pub fn signed(self, key_id: KeyId, signature: SignatureHex) -> ReceiptsRequestV2 {
408 ReceiptsRequestV2 {
409 schema: self.schema,
410 receipts: self.receipts,
411 key_id,
412 signature,
413 }
414 }
415}
416
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct ReceiptsRequestV2 {
421 pub schema: ReceiptsSchema,
422 pub receipts: Vec<ReceiptItemV2>,
423 pub key_id: KeyId,
424 pub signature: SignatureHex,
425}
426
427impl ReceiptsRequestV2 {
428 pub fn body(&self) -> ReceiptsBodyV2 {
429 ReceiptsBodyV2 {
430 schema: self.schema,
431 receipts: self.receipts.clone(),
432 }
433 }
434
435 pub fn validate(&self) -> Result<(), ValidationError> {
436 self.body().validate()
437 }
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441#[serde(deny_unknown_fields)]
442pub struct ReceiptsAckV2 {
443 pub schema: ReceiptsAckSchema,
444 pub accepted: u16,
445 pub duplicates: u16,
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn closed_strings_follow_the_contract_patterns() {
454 assert!(ReceiptHmac::new(&format!("hmac-sha256:{}", "a".repeat(64))).is_some());
455 for bad in [
456 format!("sha256:{}", "a".repeat(64)),
457 format!("hmac-sha256:{}", "A".repeat(64)),
458 "hmac-sha256:".to_string(),
459 ] {
460 assert!(ReceiptHmac::new(&bad).is_none(), "{bad}");
461 }
462 assert!(ReceiptIdV2::new("rcpt_01jb8z5").is_some());
463 assert!(ReceiptIdV2::new("engine-receipt-1").is_none());
464 assert!(InvocationIdV2::new(&"0".repeat(32)).is_some());
465 assert!(InvocationIdV2::new(&"0".repeat(31)).is_none());
466 assert!(InvocationIdV2::new("src/main.rs").is_none());
467 for good in ["3.12.0", "1.0.0", "3.12.0-rc.1"] {
468 assert!(ReceiptSemver::new(good).is_some(), "{good}");
469 }
470 for bad in ["3.12", "3.12.0-", "3.12.0-a b", "v3.12.0"] {
471 assert!(ReceiptSemver::new(bad).is_none(), "{bad}");
472 }
473 assert!(MeasurementName::new("tokens_baseline").is_some());
474 for bad in ["skprojabcdefghijklmnopqrstuvwxyz", "/etc/passwd", "A", ""] {
475 assert!(MeasurementName::new(bad).is_none(), "{bad}");
476 }
477 }
478}