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,
117 is_hmac,
118 "receipt hmac"
119);
120closed_string!(
121 Sha256Ref,
123 is_sha256,
124 "sha256 digest"
125);
126closed_string!(
127 ReceiptIdV2,
129 is_receipt_id,
130 "receipt id"
131);
132closed_string!(
133 InvocationIdV2,
135 is_invocation_id,
136 "invocation id"
137);
138closed_string!(
139 ReceiptSemver,
141 is_semver,
142 "version"
143);
144closed_string!(
145 MeasurementName,
147 is_measurement_name,
148 "measurement name"
149);
150
151schema_const!(ReceiptsSchema, RECEIPTS_V2_SCHEMA);
152schema_const!(ReceiptsAckSchema, RECEIPTS_V2_ACK_SCHEMA);
153schema_const!(EngineReceiptSchema, ENGINE_RECEIPT_V2_SCHEMA);
154schema_const!(EngineIdConst, RECEIPT_ENGINE_ID);
155schema_const!(CapabilityIdConst, RECEIPT_CAPABILITY_ID);
156schema_const!(EngineKindConst, "instance");
157
158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
160pub struct SchemaVersionOne;
161
162impl Serialize for SchemaVersionOne {
163 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
164 serializer.serialize_u8(1)
165 }
166}
167
168impl<'de> Deserialize<'de> for SchemaVersionOne {
169 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
170 (u64::deserialize(deserializer)? == 1)
171 .then_some(Self)
172 .ok_or_else(|| serde::de::Error::custom("schema_version must be 1"))
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum MeasurementUnit {
179 Tokens,
180 Bytes,
181 Ms,
182 Count,
183 Lines,
184 RatioPpm,
185}
186
187impl MeasurementUnit {
188 pub fn of(unit: &str) -> Option<Self> {
190 Some(match unit {
191 "tokens" => Self::Tokens,
192 "bytes" => Self::Bytes,
193 "ms" => Self::Ms,
194 "count" => Self::Count,
195 "lines" => Self::Lines,
196 "ratio_ppm" => Self::RatioPpm,
197 _ => return None,
198 })
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum Classification {
205 Measured,
206 Estimated,
207 Unavailable,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum AdmissionDecision {
213 Admitted,
214 Rejected,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "snake_case")]
219pub enum ObservationStatus {
220 Succeeded,
221 Degraded,
222 Rejected,
223 Failed,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum FailureCode {
229 PolicyRejected,
230 SourceUnavailable,
231 SourceIntegrityMismatch,
232 ResourceLimit,
233 UnsupportedOperation,
234 Internal,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct ReceiptEngineV2 {
240 pub instance_id: EngineIdConst,
241 pub instance_version: ReceiptSemver,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct ReceiptOperationV2 {
247 pub capability_id: CapabilityIdConst,
248 pub capability_version: ReceiptSemver,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct PolicyAdmissionV2 {
254 pub decision: AdmissionDecision,
255 pub policy_digest: Option<Sha256Ref>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct ReceiptInvocationV2 {
262 pub schema_version: SchemaVersionOne,
263 pub invocation_id: InvocationIdV2,
264 pub instance: ReceiptEngineV2,
265 pub operation: ReceiptOperationV2,
266 pub input_hmac: ReceiptHmac,
267 pub source_count: u16,
269 pub policy_admission: PolicyAdmissionV2,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub struct MeasurementV2 {
275 pub name: MeasurementName,
276 pub unit: MeasurementUnit,
277 pub classification: Classification,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub value: Option<u64>,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284pub struct FailureV2 {
285 pub code: FailureCode,
286 pub retryable_by_host: bool,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(deny_unknown_fields)]
291pub struct ReceiptObservationV2 {
292 pub schema_version: SchemaVersionOne,
293 pub invocation_id: InvocationIdV2,
294 pub status: ObservationStatus,
295 pub output_hmac: Option<ReceiptHmac>,
296 pub source_count: u16,
298 pub measurements: Vec<MeasurementV2>,
299 pub failure: Option<FailureV2>,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct EngineReceiptV2 {
306 pub schema: EngineReceiptSchema,
307 pub invocation: ReceiptInvocationV2,
308 pub observation: ReceiptObservationV2,
309}
310
311impl EngineReceiptV2 {
312 pub fn validate(&self) -> Result<(), ValidationError> {
313 let (i, o) = (&self.invocation, &self.observation);
314 if i.invocation_id != o.invocation_id {
315 return Err(ValidationError::new("receipt: invocation_id differs"));
316 }
317 if !(1..=MAX_SOURCE_COUNT).contains(&i.source_count) || o.source_count > MAX_SOURCE_COUNT {
318 return Err(ValidationError::new("receipt: source_count out of range"));
319 }
320 if o.measurements.len() > MAX_MEASUREMENTS
321 || o.measurements
322 .iter()
323 .any(|m| m.value.is_some_and(|v| v > MAX_COUNT))
324 {
325 return Err(ValidationError::new("receipt: measurements"));
326 }
327 Ok(())
328 }
329
330 pub fn digest(&self) -> Result<Sha256Ref, ValidationError> {
332 use sha2::Digest as _;
333 let value = serde_json::to_value(self)
334 .map_err(|e| ValidationError::new(format!("receipt: encode: {e}")))?;
335 let bytes = super::canonical_json(&value)?;
336 let hex: String = sha2::Sha256::digest(&bytes)
337 .iter()
338 .map(|b| format!("{b:02x}"))
339 .collect();
340 Sha256Ref::new(&format!("sha256:{hex}"))
341 .ok_or_else(|| ValidationError::new("receipt: digest"))
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(deny_unknown_fields)]
347pub struct ReceiptItemV2 {
348 pub receipt_id: ReceiptIdV2,
349 pub kind: EngineKindConst,
350 pub vault_id: VaultId,
351 pub created_at: TimeStamp,
352 pub digest: Sha256Ref,
353 pub document: EngineReceiptV2,
354}
355
356impl ReceiptItemV2 {
357 pub fn new(
359 receipt_id: ReceiptIdV2,
360 vault_id: VaultId,
361 created_at: TimeStamp,
362 document: EngineReceiptV2,
363 ) -> Result<Self, ValidationError> {
364 document.validate()?;
365 Ok(Self {
366 receipt_id,
367 kind: EngineKindConst,
368 vault_id,
369 created_at,
370 digest: document.digest()?,
371 document,
372 })
373 }
374
375 pub fn validate(&self) -> Result<(), ValidationError> {
376 self.document.validate()?;
377 if self.document.digest()? != self.digest {
378 return Err(ValidationError::new(
379 "receipt: digest does not bind the document",
380 ));
381 }
382 Ok(())
383 }
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389pub struct ReceiptsBodyV2 {
390 pub schema: ReceiptsSchema,
391 pub receipts: Vec<ReceiptItemV2>,
392}
393
394impl ReceiptsBodyV2 {
395 pub fn validate(&self) -> Result<(), ValidationError> {
396 if self.receipts.is_empty() || self.receipts.len() > MAX_RECEIPTS_V2 {
397 return Err(ValidationError::new("receipts: 1-256 per batch"));
398 }
399 let mut ids = BTreeSet::new();
400 if !self
401 .receipts
402 .iter()
403 .all(|r| ids.insert(r.receipt_id.as_str()))
404 {
405 return Err(ValidationError::new("receipts: duplicate receipt_id"));
406 }
407 self.receipts.iter().try_for_each(ReceiptItemV2::validate)
408 }
409
410 pub fn signing_input(&self, key_id: &KeyId) -> Result<Vec<u8>, ValidationError> {
413 let mut value = serde_json::to_value(self)
414 .map_err(|e| ValidationError::new(format!("receipts: encode: {e}")))?;
415 value["key_id"] = serde_json::Value::String(key_id.as_str().to_string());
416 super::signing_input(RECEIPTS_DOMAIN, &value)
417 }
418
419 pub fn signed(self, key_id: KeyId, signature: SignatureHex) -> ReceiptsRequestV2 {
420 ReceiptsRequestV2 {
421 schema: self.schema,
422 receipts: self.receipts,
423 key_id,
424 signature,
425 }
426 }
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
431#[serde(deny_unknown_fields)]
432pub struct ReceiptsRequestV2 {
433 pub schema: ReceiptsSchema,
434 pub receipts: Vec<ReceiptItemV2>,
435 pub key_id: KeyId,
436 pub signature: SignatureHex,
437}
438
439impl ReceiptsRequestV2 {
440 pub fn body(&self) -> ReceiptsBodyV2 {
441 ReceiptsBodyV2 {
442 schema: self.schema,
443 receipts: self.receipts.clone(),
444 }
445 }
446
447 pub fn validate(&self) -> Result<(), ValidationError> {
448 self.body().validate()
449 }
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453#[serde(deny_unknown_fields)]
454pub struct ReceiptsAckV2 {
455 pub schema: ReceiptsAckSchema,
456 pub accepted: u16,
457 pub duplicates: u16,
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn closed_strings_follow_the_contract_patterns() {
466 assert!(ReceiptHmac::new(&format!("hmac-sha256:{}", "a".repeat(64))).is_some());
467 for bad in [
468 format!("sha256:{}", "a".repeat(64)),
469 format!("hmac-sha256:{}", "A".repeat(64)),
470 "hmac-sha256:".to_string(),
471 ] {
472 assert!(ReceiptHmac::new(&bad).is_none(), "{bad}");
473 }
474 assert!(ReceiptIdV2::new("rcpt_01jb8z5").is_some());
475 assert!(ReceiptIdV2::new("engine-receipt-1").is_none());
476 assert!(InvocationIdV2::new(&"0".repeat(32)).is_some());
477 assert!(InvocationIdV2::new(&"0".repeat(31)).is_none());
478 assert!(InvocationIdV2::new("src/main.rs").is_none());
479 for good in ["3.12.0", "1.0.0", "3.12.0-rc.1"] {
480 assert!(ReceiptSemver::new(good).is_some(), "{good}");
481 }
482 for bad in ["3.12", "3.12.0-", "3.12.0-a b", "v3.12.0"] {
483 assert!(ReceiptSemver::new(bad).is_none(), "{bad}");
484 }
485 assert!(MeasurementName::new("tokens_baseline").is_some());
486 for bad in ["skprojabcdefghijklmnopqrstuvwxyz", "/etc/passwd", "A", ""] {
487 assert!(MeasurementName::new(bad).is_none(), "{bad}");
488 }
489 }
490}