1use serde::{Deserialize, Deserializer, Serialize};
18use serde_json::Value;
19use sha2::{Digest, Sha256};
20use termivar_core::{
21 ApiSurfaceKind, ApiVisibilityComparison, ApiVisibilityDimension, ApiVisibilityPairKind,
22 ApiVisibilityResult, ApiVocabularyError,
23};
24use thiserror::Error;
25
26mod profiled;
27
28pub use profiled::{
29 ApiComparisonProfile, CanonicalizationVersion, ComparisonAlgorithmVersion, JsonPathPattern,
30 PathDigest, ProfiledApiVisibilityComparison, ProfiledApiVisibilityError,
31 ProfiledApiVisibilityView, ProjectionPolicyId, RedactedVisibilityDiff,
32 VisibilityExplanationDisposition, CURRENT_API_COMPARISON_ALGORITHM_VERSION,
33 CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION, DEFAULT_API_VISIBILITY_DIFF_PATHS,
34 HARD_MAX_API_COMPARISON_PATH_BYTES, HARD_MAX_API_COMPARISON_PATH_DEPTH,
35 HARD_MAX_API_COMPARISON_PROFILE_PATHS, HARD_MAX_API_VISIBILITY_DIFF_PATHS,
36};
37
38const MAX_OPAQUE_HANDLE_BYTES: usize = 256;
39const RESOURCE_SIGNATURE_DOMAIN: &[u8] = b"venom.api-visibility.resource.v1\0";
40const FIELD_SIGNATURE_DOMAIN: &[u8] = b"venom.api-visibility.fields.v1\0";
41
42pub const HARD_MAX_API_VISIBILITY_DEPTH: u16 = 128;
44pub const HARD_MAX_API_VISIBILITY_NODES: u32 = 1_000_000;
46pub const HARD_MAX_API_VISIBILITY_FIELDS: u32 = 250_000;
48pub const HARD_MAX_API_VISIBILITY_CANONICAL_BYTES: u64 = 64 * 1024 * 1024;
50
51pub const DEFAULT_API_VISIBILITY_DEPTH: u16 = 64;
53pub const DEFAULT_API_VISIBILITY_NODES: u32 = 100_000;
55pub const DEFAULT_API_VISIBILITY_FIELDS: u32 = 50_000;
57pub const DEFAULT_API_VISIBILITY_CANONICAL_BYTES: u64 = 8 * 1024 * 1024;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71pub struct ApiVisibilityLimits {
72 max_depth: u16,
73 max_nodes: u32,
74 max_fields: u32,
75 max_canonical_bytes: u64,
76}
77
78impl ApiVisibilityLimits {
79 pub fn new(
81 max_depth: u16,
82 max_nodes: u32,
83 max_fields: u32,
84 max_canonical_bytes: u64,
85 ) -> Result<Self, ApiVisibilityEvidenceError> {
86 validate_limit(
87 "max_depth",
88 u64::from(max_depth),
89 u64::from(HARD_MAX_API_VISIBILITY_DEPTH),
90 )?;
91 validate_limit(
92 "max_nodes",
93 u64::from(max_nodes),
94 u64::from(HARD_MAX_API_VISIBILITY_NODES),
95 )?;
96 validate_limit(
97 "max_fields",
98 u64::from(max_fields),
99 u64::from(HARD_MAX_API_VISIBILITY_FIELDS),
100 )?;
101 validate_limit(
102 "max_canonical_bytes",
103 max_canonical_bytes,
104 HARD_MAX_API_VISIBILITY_CANONICAL_BYTES,
105 )?;
106 Ok(Self {
107 max_depth,
108 max_nodes,
109 max_fields,
110 max_canonical_bytes,
111 })
112 }
113
114 pub const fn max_depth(self) -> u16 {
116 self.max_depth
117 }
118
119 pub const fn max_nodes(self) -> u32 {
121 self.max_nodes
122 }
123
124 pub const fn max_fields(self) -> u32 {
126 self.max_fields
127 }
128
129 pub const fn max_canonical_bytes(self) -> u64 {
131 self.max_canonical_bytes
132 }
133}
134
135impl Default for ApiVisibilityLimits {
136 fn default() -> Self {
137 Self {
138 max_depth: DEFAULT_API_VISIBILITY_DEPTH,
139 max_nodes: DEFAULT_API_VISIBILITY_NODES,
140 max_fields: DEFAULT_API_VISIBILITY_FIELDS,
141 max_canonical_bytes: DEFAULT_API_VISIBILITY_CANONICAL_BYTES,
142 }
143 }
144}
145
146impl<'de> Deserialize<'de> for ApiVisibilityLimits {
147 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
148 where
149 D: Deserializer<'de>,
150 {
151 #[derive(Deserialize)]
152 #[serde(deny_unknown_fields)]
153 struct WireLimits {
154 max_depth: u16,
155 max_nodes: u32,
156 max_fields: u32,
157 max_canonical_bytes: u64,
158 }
159
160 let wire = WireLimits::deserialize(deserializer)?;
161 Self::new(
162 wire.max_depth,
163 wire.max_nodes,
164 wire.max_fields,
165 wire.max_canonical_bytes,
166 )
167 .map_err(serde::de::Error::custom)
168 }
169}
170
171#[derive(Clone, PartialEq, Eq)]
184pub struct ApiVisibilityView {
185 context_id: String,
186 resource_scope_id: String,
187 surface: ApiSurfaceKind,
188 status: u16,
189 resource_signature: [u8; 32],
190 field_signature: [u8; 32],
191 limits: ApiVisibilityLimits,
192}
193
194#[cfg(feature = "rest-review")]
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub(crate) struct ApiExactReplayComparison {
202 status: bool,
203 fields: bool,
204 resources: bool,
205}
206
207#[cfg(feature = "rest-review")]
208impl ApiExactReplayComparison {
209 pub(crate) const fn status(self) -> bool {
210 self.status
211 }
212
213 pub(crate) const fn fields(self) -> bool {
214 self.fields
215 }
216
217 pub(crate) const fn resources(self) -> bool {
218 self.resources
219 }
220
221 pub(crate) const fn all_equivalent(self) -> bool {
222 self.status && self.fields && self.resources
223 }
224}
225
226impl std::fmt::Debug for ApiVisibilityView {
227 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 formatter
229 .debug_struct("ApiVisibilityView")
230 .field("context_id", &"<redacted>")
231 .field("resource_scope_id", &"<redacted>")
232 .field("surface", &self.surface)
233 .field("status", &self.status)
234 .field("resource_signature", &"<redacted>")
235 .field("field_signature", &"<redacted>")
236 .field("limits", &self.limits)
237 .finish()
238 }
239}
240
241impl ApiVisibilityView {
242 pub fn context_id(&self) -> &str {
244 &self.context_id
245 }
246
247 pub fn resource_scope_id(&self) -> &str {
249 &self.resource_scope_id
250 }
251
252 pub const fn surface(&self) -> ApiSurfaceKind {
254 self.surface
255 }
256
257 pub const fn status(&self) -> u16 {
259 self.status
260 }
261
262 pub const fn limits(&self) -> ApiVisibilityLimits {
264 self.limits
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub struct ApiVisibilityComparator {
325 limits: ApiVisibilityLimits,
326}
327
328impl ApiVisibilityComparator {
329 pub const fn new(limits: ApiVisibilityLimits) -> Self {
331 Self { limits }
332 }
333
334 pub const fn limits(&self) -> ApiVisibilityLimits {
336 self.limits
337 }
338
339 pub fn capture_view(
351 &self,
352 context_id: impl Into<String>,
353 resource_scope_id: impl Into<String>,
354 surface: ApiSurfaceKind,
355 status: u16,
356 snapshot: &Value,
357 ) -> Result<ApiVisibilityView, ApiVisibilityEvidenceError> {
358 let context_id = opaque_handle(context_id, "context id")?;
359 let resource_scope_id = opaque_handle(resource_scope_id, "resource scope id")?;
360 if !(100..=599).contains(&status) {
361 return Err(ApiVisibilityEvidenceError::InvalidHttpStatus { status });
362 }
363
364 let signatures = canonical_signatures(snapshot, self.limits)?;
365 Ok(ApiVisibilityView {
366 context_id,
367 resource_scope_id,
368 surface,
369 status,
370 resource_signature: signatures.resource,
371 field_signature: signatures.fields,
372 limits: self.limits,
373 })
374 }
375
376 #[allow(clippy::too_many_arguments)]
383 pub fn compare(
384 &self,
385 comparison_id: impl Into<String>,
386 pair: ApiVisibilityPairKind,
387 dimension: ApiVisibilityDimension,
388 baseline: &ApiVisibilityView,
389 candidate: &ApiVisibilityView,
390 observed_at_ms: u64,
391 ) -> Result<ApiVisibilityComparison, ApiVisibilityEvidenceError> {
392 if baseline.limits != self.limits || candidate.limits != self.limits {
393 return Err(ApiVisibilityEvidenceError::LimitsMismatch);
394 }
395 if baseline.context_id == candidate.context_id {
396 return Err(ApiVisibilityEvidenceError::IdenticalContexts);
397 }
398 if baseline.resource_scope_id != candidate.resource_scope_id {
399 return Err(ApiVisibilityEvidenceError::ResourceScopeMismatch);
400 }
401 if baseline.surface != candidate.surface {
402 return Err(ApiVisibilityEvidenceError::SurfaceMismatch);
403 }
404
405 let equivalent = match dimension {
406 ApiVisibilityDimension::Fields => baseline.field_signature == candidate.field_signature,
407 ApiVisibilityDimension::Resources => {
408 baseline.resource_signature == candidate.resource_signature
409 },
410 ApiVisibilityDimension::Status => baseline.status == candidate.status,
411 _ => return Err(ApiVisibilityEvidenceError::UnsupportedDimension),
412 };
413 let result = if equivalent {
414 ApiVisibilityResult::Equivalent
415 } else {
416 ApiVisibilityResult::Different
417 };
418
419 Ok(ApiVisibilityComparison::new(
420 comparison_id,
421 baseline.surface,
422 pair,
423 result,
424 dimension,
425 baseline.context_id.clone(),
426 candidate.context_id.clone(),
427 baseline.resource_scope_id.clone(),
428 )?
429 .with_observed_at_ms(observed_at_ms))
430 }
431
432 #[cfg(feature = "rest-review")]
439 pub(crate) fn compare_exact_replay(
440 &self,
441 candidate: &ApiVisibilityView,
442 replay: &ApiVisibilityView,
443 ) -> Result<ApiExactReplayComparison, ApiVisibilityEvidenceError> {
444 if candidate.limits != self.limits || replay.limits != self.limits {
445 return Err(ApiVisibilityEvidenceError::LimitsMismatch);
446 }
447 if candidate.context_id == replay.context_id {
448 return Err(ApiVisibilityEvidenceError::IdenticalContexts);
449 }
450 if candidate.resource_scope_id != replay.resource_scope_id {
451 return Err(ApiVisibilityEvidenceError::ResourceScopeMismatch);
452 }
453 if candidate.surface != replay.surface {
454 return Err(ApiVisibilityEvidenceError::SurfaceMismatch);
455 }
456
457 Ok(ApiExactReplayComparison {
458 status: candidate.status == replay.status,
459 fields: candidate.field_signature == replay.field_signature,
460 resources: candidate.resource_signature == replay.resource_signature,
461 })
462 }
463}
464
465impl Default for ApiVisibilityComparator {
466 fn default() -> Self {
467 Self::new(ApiVisibilityLimits::default())
468 }
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Error)]
473#[non_exhaustive]
474pub enum ApiVisibilityEvidenceError {
475 #[error("API visibility limit {dimension} must be greater than zero")]
477 ZeroLimit { dimension: &'static str },
478
479 #[error("API visibility limit {dimension} value {actual} exceeds hard ceiling {maximum}")]
481 HardLimitExceeded {
482 dimension: &'static str,
484 actual: u64,
486 maximum: u64,
488 },
489
490 #[error("API visibility {field} must not be empty")]
492 EmptyHandle { field: &'static str },
493
494 #[error("API visibility {field} exceeds the {maximum}-byte limit")]
496 HandleTooLong {
497 field: &'static str,
499 maximum: usize,
501 },
502
503 #[error("API visibility HTTP status {status} is outside 100..=599")]
505 InvalidHttpStatus { status: u16 },
506
507 #[error("API visibility JSON depth {observed} exceeds limit {limit}")]
509 DepthLimitExceeded { limit: u16, observed: u64 },
510
511 #[error("API visibility JSON node count {observed} exceeds limit {limit}")]
513 NodeLimitExceeded { limit: u32, observed: u64 },
514
515 #[error("API visibility JSON field count {observed} exceeds limit {limit}")]
517 FieldLimitExceeded { limit: u32, observed: u64 },
518
519 #[error(
521 "API visibility {signature} canonical stream reached {observed} bytes, above limit {limit}"
522 )]
523 CanonicalBytesLimitExceeded {
524 signature: &'static str,
526 limit: u64,
528 observed: u64,
530 },
531
532 #[error("API visibility views were captured under different limits")]
534 LimitsMismatch,
535
536 #[error("API visibility comparison contexts must be different")]
538 IdenticalContexts,
539
540 #[error("API visibility comparison resource scopes do not match")]
542 ResourceScopeMismatch,
543
544 #[error("API visibility comparison surfaces do not match")]
546 SurfaceMismatch,
547
548 #[error("API visibility comparison dimension is unsupported by this comparator")]
550 UnsupportedDimension,
551
552 #[error(transparent)]
554 Vocabulary(#[from] ApiVocabularyError),
555}
556
557fn validate_limit(
558 dimension: &'static str,
559 actual: u64,
560 maximum: u64,
561) -> Result<(), ApiVisibilityEvidenceError> {
562 if actual == 0 {
563 return Err(ApiVisibilityEvidenceError::ZeroLimit { dimension });
564 }
565 if actual > maximum {
566 return Err(ApiVisibilityEvidenceError::HardLimitExceeded {
567 dimension,
568 actual,
569 maximum,
570 });
571 }
572 Ok(())
573}
574
575fn opaque_handle(
576 value: impl Into<String>,
577 field: &'static str,
578) -> Result<String, ApiVisibilityEvidenceError> {
579 let value = value.into();
580 if value.trim().is_empty() {
581 return Err(ApiVisibilityEvidenceError::EmptyHandle { field });
582 }
583 if value.len() > MAX_OPAQUE_HANDLE_BYTES {
584 return Err(ApiVisibilityEvidenceError::HandleTooLong {
585 field,
586 maximum: MAX_OPAQUE_HANDLE_BYTES,
587 });
588 }
589 Ok(value)
590}
591
592struct CanonicalSignatures {
593 resource: [u8; 32],
594 fields: [u8; 32],
595}
596
597fn canonical_signatures(
598 value: &Value,
599 limits: ApiVisibilityLimits,
600) -> Result<CanonicalSignatures, ApiVisibilityEvidenceError> {
601 let mut state = CanonicalState {
602 limits,
603 nodes: 0,
604 fields: 0,
605 resource: SignatureWriter::new("resources", RESOURCE_SIGNATURE_DOMAIN, limits),
606 schema: SignatureWriter::new("fields", FIELD_SIGNATURE_DOMAIN, limits),
607 };
608 state.visit(value, 1)?;
609 Ok(CanonicalSignatures {
610 resource: state.resource.finish(),
611 fields: state.schema.finish(),
612 })
613}
614
615struct CanonicalState {
616 limits: ApiVisibilityLimits,
617 nodes: u64,
618 fields: u64,
619 resource: SignatureWriter,
620 schema: SignatureWriter,
621}
622
623impl CanonicalState {
624 fn visit(&mut self, value: &Value, depth: u64) -> Result<(), ApiVisibilityEvidenceError> {
625 if depth > u64::from(self.limits.max_depth) {
626 return Err(ApiVisibilityEvidenceError::DepthLimitExceeded {
627 limit: self.limits.max_depth,
628 observed: depth,
629 });
630 }
631 self.nodes = self.nodes.saturating_add(1);
632 if self.nodes > u64::from(self.limits.max_nodes) {
633 return Err(ApiVisibilityEvidenceError::NodeLimitExceeded {
634 limit: self.limits.max_nodes,
635 observed: self.nodes,
636 });
637 }
638
639 match value {
640 Value::Null => {
641 self.resource.write(b"0")?;
642 self.schema.write(b"0")?;
643 },
644 Value::Bool(value) => {
645 self.resource.write(if *value { b"t" } else { b"f" })?;
646 self.schema.write(b"b")?;
647 },
648 Value::Number(value) => {
649 self.resource.write(b"n")?;
650 self.resource.write_framed(value.to_string().as_bytes())?;
651 self.schema.write(if value.is_i64() || value.is_u64() {
652 b"i"
653 } else {
654 b"n"
655 })?;
656 },
657 Value::String(value) => {
658 self.resource.write(b"s")?;
659 self.resource.write_framed(value.as_bytes())?;
660 self.schema.write(b"s")?;
661 },
662 Value::Array(values) => {
663 self.resource.write(b"[")?;
664 self.resource.write_len(values.len())?;
665 self.schema.write(b"[")?;
666 self.schema.write_len(values.len())?;
667 for value in values {
668 self.visit(value, depth.saturating_add(1))?;
669 }
670 self.resource.write(b"]")?;
671 self.schema.write(b"]")?;
672 },
673 Value::Object(values) => {
674 self.fields = self
675 .fields
676 .saturating_add(u64::try_from(values.len()).unwrap_or(u64::MAX));
677 if self.fields > u64::from(self.limits.max_fields) {
678 return Err(ApiVisibilityEvidenceError::FieldLimitExceeded {
679 limit: self.limits.max_fields,
680 observed: self.fields,
681 });
682 }
683
684 self.resource.write(b"{")?;
685 self.resource.write_len(values.len())?;
686 self.schema.write(b"{")?;
687 self.schema.write_len(values.len())?;
688 let mut entries = values.iter().collect::<Vec<_>>();
689 entries.sort_by(|(left, _), (right, _)| left.as_bytes().cmp(right.as_bytes()));
690 for (key, value) in entries {
691 self.resource.write_framed(key.as_bytes())?;
692 self.schema.write_framed(key.as_bytes())?;
693 self.visit(value, depth.saturating_add(1))?;
694 }
695 self.resource.write(b"}")?;
696 self.schema.write(b"}")?;
697 },
698 }
699 Ok(())
700 }
701}
702
703struct SignatureWriter {
704 name: &'static str,
705 limit: u64,
706 written: u64,
707 hasher: Sha256,
708}
709
710impl SignatureWriter {
711 fn new(name: &'static str, domain: &[u8], limits: ApiVisibilityLimits) -> Self {
712 let mut hasher = Sha256::new();
713 hasher.update(domain);
714 Self {
715 name,
716 limit: limits.max_canonical_bytes,
717 written: 0,
718 hasher,
719 }
720 }
721
722 fn write(&mut self, bytes: &[u8]) -> Result<(), ApiVisibilityEvidenceError> {
723 let observed = self
724 .written
725 .saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
726 if observed > self.limit {
727 return Err(ApiVisibilityEvidenceError::CanonicalBytesLimitExceeded {
728 signature: self.name,
729 limit: self.limit,
730 observed,
731 });
732 }
733 self.hasher.update(bytes);
734 self.written = observed;
735 Ok(())
736 }
737
738 fn write_len(&mut self, length: usize) -> Result<(), ApiVisibilityEvidenceError> {
739 self.write(&u64::try_from(length).unwrap_or(u64::MAX).to_be_bytes())
740 }
741
742 fn write_framed(&mut self, bytes: &[u8]) -> Result<(), ApiVisibilityEvidenceError> {
743 self.write_len(bytes.len())?;
744 self.write(bytes)
745 }
746
747 fn finish(self) -> [u8; 32] {
748 self.hasher.finalize().into()
749 }
750}
751
752#[cfg(test)]
753#[path = "api_evidence_tests.rs"]
754mod tests;