1use crate::{knowledge::MAX_KNOWLEDGE_RELATION_ID_BYTES, rules::RuleEngineError};
18
19mod cursor;
20mod ingest;
21mod model;
22mod query;
23mod review;
24
25pub use cursor::ApiVisibilityReviewCursor;
26pub use ingest::ingest_api_visibility_observation;
27pub use model::{ApiObservationCommitReceipt, ApiObservationError, ApiObservationReceipt};
28pub use query::{
29 api_visibility_reviews_for_resource, api_visibility_reviews_for_resource_v2,
30 ApiVisibilityReviewPage, ApiVisibilityReviewQuery,
31};
32#[cfg(feature = "scanning")]
33pub(crate) use review::api_visibility_review_for_commit;
34pub use review::{ApiVisibilityReview, ApiVisibilityReviewDisposition};
35
36#[cfg(test)]
37use crate::{
38 knowledge::{KnowledgeBase, KnowledgeBaseError, KnowledgeWrite},
39 rules::{hypothesis_id_for_rule, RuleApplication, RuleEngine},
40};
41#[cfg(test)]
42use termivar_core::{
43 ApiEvidencePredicate, ApiKnowledgePredicate, ApiVisibilityBoundaryKind, ApiVisibilityDimension,
44 ApiVisibilityObservation, ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind,
45 EvidenceValue, Hypothesis, KnowledgePredicate, RelationId, RelationKind,
46};
47
48const API_VISIBILITY_RELATION: &str = "api.visibility.resource-scope";
49const API_VISIBILITY_EVIDENCE_KIND: &str = "api.visibility-comparison";
50const API_VISIBILITY_SOURCE_METHOD: &str = "paired-api-visibility";
51const COMPARISON_SUBJECT_PREFIX: &str = "api-comparison:";
52const COMPARISON_EVIDENCE_PREFIX: &str = "api-comparison-evidence:";
53const COMPARISON_RELATION_PREFIX: &str = "api-comparison-scope:";
54const UI_API_BOUNDARY_RULE: &str = "api.visibility.ui-api.paired-difference";
55const AUTHORIZATION_BOUNDARY_RULE: &str = "api.visibility.authorization-context.paired-difference";
56const API_VISIBILITY_REVIEW_CURSOR_PREFIX: &str = "venom-api-review-v2:";
57const API_VISIBILITY_REVIEW_CURSOR_DOMAIN: &[u8] = b"venom.api-visibility.review-cursor.v2\0";
58const API_VISIBILITY_REVIEW_RESOURCE_DIGEST_HEX_BYTES: usize = 64;
59
60pub const DEFAULT_API_VISIBILITY_REVIEW_SCAN_LIMIT: u16 = 128;
62pub const HARD_MAX_API_VISIBILITY_REVIEW_SCAN_LIMIT: u16 = 1_024;
64pub const MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES: usize = 256;
66pub const MAX_API_VISIBILITY_REVIEW_RATIONALE_BYTES: usize = 1_024;
68pub const MAX_API_VISIBILITY_REVIEW_CURSOR_BYTES: usize = API_VISIBILITY_REVIEW_CURSOR_PREFIX.len()
70 + API_VISIBILITY_REVIEW_RESOURCE_DIGEST_HEX_BYTES
71 + 1
72 + (MAX_KNOWLEDGE_RELATION_ID_BYTES * 2);
73
74impl ApiObservationError {
75 pub fn committed_observation(&self) -> Option<&ApiObservationCommitReceipt> {
77 match self {
78 Self::ReasoningAfterCommit { commit, .. } => Some(commit),
79 Self::ResourceMismatch { .. }
80 | Self::ZeroReviewScanLimit
81 | Self::ReviewScanLimitExceeded { .. }
82 | Self::ReviewCursorTooLong { .. }
83 | Self::ResourceBoundReviewCursorTooLong { .. }
84 | Self::InvalidResourceBoundReviewCursor { .. }
85 | Self::UnsupportedResourceBoundReviewCursorVersion
86 | Self::ResourceBoundReviewCursorMismatch
87 | Self::ObservationLimitExceeded { .. }
88 | Self::Knowledge(_) => None,
89 }
90 }
91
92 pub fn into_committed_observation(self) -> Option<ApiObservationCommitReceipt> {
94 match self {
95 Self::ReasoningAfterCommit { commit, .. } => Some(*commit),
96 Self::ResourceMismatch { .. }
97 | Self::ZeroReviewScanLimit
98 | Self::ReviewScanLimitExceeded { .. }
99 | Self::ReviewCursorTooLong { .. }
100 | Self::ResourceBoundReviewCursorTooLong { .. }
101 | Self::InvalidResourceBoundReviewCursor { .. }
102 | Self::UnsupportedResourceBoundReviewCursorVersion
103 | Self::ResourceBoundReviewCursorMismatch
104 | Self::ObservationLimitExceeded { .. }
105 | Self::Knowledge(_) => None,
106 }
107 }
108
109 pub fn reasoning_source(&self) -> Option<&RuleEngineError> {
111 match self {
112 Self::ReasoningAfterCommit { source, .. } => Some(source),
113 Self::ResourceMismatch { .. }
114 | Self::ZeroReviewScanLimit
115 | Self::ReviewScanLimitExceeded { .. }
116 | Self::ReviewCursorTooLong { .. }
117 | Self::ResourceBoundReviewCursorTooLong { .. }
118 | Self::InvalidResourceBoundReviewCursor { .. }
119 | Self::UnsupportedResourceBoundReviewCursorVersion
120 | Self::ResourceBoundReviewCursorMismatch
121 | Self::ObservationLimitExceeded { .. }
122 | Self::Knowledge(_) => None,
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use termivar_core::{
130 ApiSurfaceKind, ApiVisibilityComparison, ApiVisibilityPairKind, ApiVisibilityResult,
131 BayesianEvidence, EvidenceSource, HypothesisState, HypothesisStrength, Probability,
132 };
133
134 use super::*;
135 use crate::{
136 api_reasoning::StandardApiReasoning,
137 rules::{
138 EvidenceCalibration, EvidenceSelector, Expression, HypothesisConclusion,
139 KnowledgeLayer, ReasoningRule,
140 },
141 };
142
143 fn resource() -> EntityId {
144 EntityId::new("resource:account-42").unwrap()
145 }
146
147 fn comparison(
148 id: &str,
149 result: ApiVisibilityResult,
150 pair: ApiVisibilityPairKind,
151 ) -> ApiVisibilityObservation {
152 comparison_with_source(id, result, pair, "host.api-comparator")
153 }
154
155 fn comparison_with_source(
156 id: &str,
157 result: ApiVisibilityResult,
158 pair: ApiVisibilityPairKind,
159 source_component: impl Into<String>,
160 ) -> ApiVisibilityObservation {
161 ApiVisibilityComparison::new(
162 id,
163 ApiSurfaceKind::JsonHttp,
164 pair,
165 result,
166 ApiVisibilityDimension::Fields,
167 "anonymous-view",
168 "member-view",
169 resource().as_str(),
170 )
171 .unwrap()
172 .with_observed_at_ms(1_000)
173 .to_observation(source_component, ConfidenceScore::MAX)
174 .unwrap()
175 }
176
177 fn installed() -> (KnowledgeBase, RuleEngine) {
178 let knowledge = KnowledgeBase::new();
179 let mut rules = RuleEngine::new();
180 StandardApiReasoning::new()
181 .unwrap()
182 .install(&knowledge, &mut rules)
183 .unwrap();
184 (knowledge, rules)
185 }
186
187 fn insert_forged_boundary(
188 knowledge: &KnowledgeBase,
189 evidence: &Evidence,
190 rule_id: &str,
191 boundary: ApiVisibilityBoundaryKind,
192 ) {
193 let mut hypothesis = Hypothesis::with_id(
194 hypothesis_id_for_rule(rule_id, evidence.subject()),
195 evidence.subject().clone(),
196 ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into_knowledge(),
197 EvidenceValue::from(boundary),
198 Probability::from_percent(10).unwrap(),
199 )
200 .unwrap();
201 hypothesis
202 .observe(
203 BayesianEvidence::new(
204 evidence.id().clone(),
205 Probability::from_percent(98).unwrap(),
206 Probability::from_percent(2).unwrap(),
207 "deliberately forged boundary",
208 )
209 .unwrap(),
210 )
211 .unwrap();
212 hypothesis.set_strength(HypothesisStrength::Weak);
213 hypothesis.set_state(HypothesisState::Supported);
214 knowledge.upsert_hypothesis(hypothesis).unwrap();
215 }
216
217 #[test]
218 fn different_comparison_commits_and_projects_only_the_boundary() {
219 let (knowledge, rules) = installed();
220 let receipt = ingest_api_visibility_observation(
221 comparison(
222 "different",
223 ApiVisibilityResult::Different,
224 ApiVisibilityPairKind::AuthorizationContext,
225 ),
226 &resource(),
227 &knowledge,
228 &rules,
229 )
230 .unwrap();
231
232 assert_eq!(receipt.commit().evidence_write(), KnowledgeWrite::Inserted);
233 assert_eq!(receipt.commit().relation_write(), KnowledgeWrite::Inserted);
234 let page = api_visibility_reviews_for_resource(
235 &knowledge,
236 &resource(),
237 &ApiVisibilityReviewQuery::default(),
238 );
239 let reviews = page.reviews();
240 assert_eq!(reviews.len(), 1);
241 assert_eq!(
242 reviews[0].disposition(),
243 ApiVisibilityReviewDisposition::AwaitHumanReview
244 );
245 assert_eq!(reviews[0].boundary_hypotheses().len(), 1);
246 let boundary = &reviews[0].boundary_hypotheses()[0];
247 assert_eq!(
248 boundary.value(),
249 &EvidenceValue::from(ApiVisibilityBoundaryKind::AuthorizationContext)
250 );
251 assert_eq!(boundary.state(), HypothesisState::Supported);
252 assert!(knowledge
253 .hypotheses_for_subject(receipt.commit().comparison_subject())
254 .iter()
255 .any(|hypothesis| hypothesis.predicate()
256 == &ApiKnowledgePredicate::SURFACE_KIND.into_knowledge()));
257 }
258
259 #[test]
260 fn equivalent_comparison_has_a_surface_but_no_boundary() {
261 let (knowledge, rules) = installed();
262 let receipt = ingest_api_visibility_observation(
263 comparison(
264 "equivalent",
265 ApiVisibilityResult::Equivalent,
266 ApiVisibilityPairKind::UiApi,
267 ),
268 &resource(),
269 &knowledge,
270 &rules,
271 )
272 .unwrap();
273
274 let hypotheses = knowledge.hypotheses_for_subject(receipt.commit().comparison_subject());
275 assert!(hypotheses.iter().any(|hypothesis| hypothesis.predicate()
276 == &ApiKnowledgePredicate::SURFACE_KIND.into_knowledge()));
277 assert!(!hypotheses.iter().any(|hypothesis| hypothesis.predicate()
278 == &ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into_knowledge()));
279 let page = api_visibility_reviews_for_resource(
280 &knowledge,
281 &resource(),
282 &ApiVisibilityReviewQuery::default(),
283 );
284 let reviews = page.reviews();
285 assert_eq!(reviews.len(), 1);
286 assert_eq!(
287 reviews[0].disposition(),
288 ApiVisibilityReviewDisposition::NoDifferenceObserved
289 );
290 assert!(reviews[0].boundary_hypotheses().is_empty());
291 }
292
293 #[test]
294 fn difference_without_reasoning_remains_explicitly_unresolved() {
295 let knowledge = KnowledgeBase::new();
296 let rules = RuleEngine::new();
297 ingest_api_visibility_observation(
298 comparison(
299 "difference-without-rules",
300 ApiVisibilityResult::Different,
301 ApiVisibilityPairKind::AuthorizationContext,
302 ),
303 &resource(),
304 &knowledge,
305 &rules,
306 )
307 .unwrap();
308
309 let page = api_visibility_reviews_for_resource(
310 &knowledge,
311 &resource(),
312 &ApiVisibilityReviewQuery::default(),
313 );
314 assert_eq!(page.reviews().len(), 1);
315 assert!(page.reviews()[0].boundary_hypotheses().is_empty());
316 assert_eq!(
317 page.reviews()[0].disposition(),
318 ApiVisibilityReviewDisposition::UnresolvedDifference
319 );
320 }
321
322 #[test]
323 fn equivalent_evidence_cannot_be_projected_as_a_forged_boundary() {
324 let (knowledge, rules) = installed();
325 let receipt = ingest_api_visibility_observation(
326 comparison(
327 "equivalent-forgery",
328 ApiVisibilityResult::Equivalent,
329 ApiVisibilityPairKind::UiApi,
330 ),
331 &resource(),
332 &knowledge,
333 &rules,
334 )
335 .unwrap();
336 let evidence = knowledge.evidence(receipt.commit().evidence_id()).unwrap();
337 insert_forged_boundary(
338 &knowledge,
339 &evidence,
340 UI_API_BOUNDARY_RULE,
341 ApiVisibilityBoundaryKind::UiApi,
342 );
343
344 let page = api_visibility_reviews_for_resource(
345 &knowledge,
346 &resource(),
347 &ApiVisibilityReviewQuery::default(),
348 );
349 assert_eq!(page.reviews().len(), 1);
350 assert!(page.reviews()[0].boundary_hypotheses().is_empty());
351 }
352
353 #[test]
354 fn authorization_evidence_ignores_a_forged_ui_boundary() {
355 let (knowledge, rules) = installed();
356 let receipt = ingest_api_visibility_observation(
357 comparison(
358 "pair-forgery",
359 ApiVisibilityResult::Different,
360 ApiVisibilityPairKind::AuthorizationContext,
361 ),
362 &resource(),
363 &knowledge,
364 &rules,
365 )
366 .unwrap();
367 let evidence = knowledge.evidence(receipt.commit().evidence_id()).unwrap();
368 insert_forged_boundary(
369 &knowledge,
370 &evidence,
371 UI_API_BOUNDARY_RULE,
372 ApiVisibilityBoundaryKind::UiApi,
373 );
374
375 let page = api_visibility_reviews_for_resource(
376 &knowledge,
377 &resource(),
378 &ApiVisibilityReviewQuery::default(),
379 );
380 let boundaries = page.reviews()[0].boundary_hypotheses();
381 assert_eq!(boundaries.len(), 1);
382 assert_eq!(
383 boundaries[0].value(),
384 &EvidenceValue::from(ApiVisibilityBoundaryKind::AuthorizationContext)
385 );
386 }
387
388 #[test]
389 fn nonweak_boundary_is_not_promoted_to_human_review() {
390 let knowledge = KnowledgeBase::new();
391 let rules = RuleEngine::new();
392 let receipt = ingest_api_visibility_observation(
393 comparison(
394 "strong-boundary-forgery",
395 ApiVisibilityResult::Different,
396 ApiVisibilityPairKind::AuthorizationContext,
397 ),
398 &resource(),
399 &knowledge,
400 &rules,
401 )
402 .unwrap();
403 let evidence = knowledge.evidence(receipt.commit().evidence_id()).unwrap();
404 insert_forged_boundary(
405 &knowledge,
406 &evidence,
407 AUTHORIZATION_BOUNDARY_RULE,
408 ApiVisibilityBoundaryKind::AuthorizationContext,
409 );
410 let mut forged = knowledge
411 .hypotheses_for_subject(evidence.subject())
412 .into_iter()
413 .next()
414 .unwrap();
415 forged.set_strength(HypothesisStrength::Strong);
416 knowledge.upsert_hypothesis(forged).unwrap();
417
418 let page = api_visibility_reviews_for_resource(
419 &knowledge,
420 &resource(),
421 &ApiVisibilityReviewQuery::default(),
422 );
423 assert!(page.reviews()[0].boundary_hypotheses().is_empty());
424 assert_eq!(
425 page.reviews()[0].disposition(),
426 ApiVisibilityReviewDisposition::UnresolvedDifference
427 );
428 }
429
430 #[test]
431 fn exact_replay_is_idempotent_across_storage_and_reasoning() {
432 let (knowledge, rules) = installed();
433 let observation = comparison(
434 "replay",
435 ApiVisibilityResult::Different,
436 ApiVisibilityPairKind::UiApi,
437 );
438 ingest_api_visibility_observation(observation.clone(), &resource(), &knowledge, &rules)
439 .unwrap();
440 let replay =
441 ingest_api_visibility_observation(observation, &resource(), &knowledge, &rules)
442 .unwrap();
443
444 assert_eq!(replay.commit().evidence_write(), KnowledgeWrite::Unchanged);
445 assert_eq!(replay.commit().relation_write(), KnowledgeWrite::Unchanged);
446 assert!(replay
447 .applications()
448 .iter()
449 .filter_map(RuleApplication::write)
450 .all(|write| write == KnowledgeWrite::Unchanged));
451 assert_eq!(
452 api_visibility_reviews_for_resource(
453 &knowledge,
454 &resource(),
455 &ApiVisibilityReviewQuery::default(),
456 )
457 .reviews()
458 .len(),
459 1
460 );
461 }
462
463 #[test]
464 fn observation_and_review_debug_output_redacts_opaque_identifiers() {
465 let (knowledge, rules) = installed();
466 let receipt = ingest_api_visibility_observation(
467 comparison(
468 "debug-redaction",
469 ApiVisibilityResult::Different,
470 ApiVisibilityPairKind::UiApi,
471 ),
472 &resource(),
473 &knowledge,
474 &rules,
475 )
476 .unwrap();
477 let receipt_debug = format!("{receipt:?}");
478 for opaque in [
479 receipt.commit().comparison_subject().as_str(),
480 receipt.commit().resource_scope().as_str(),
481 receipt.commit().evidence_id().as_str(),
482 receipt.commit().relation_id().as_str(),
483 ] {
484 assert!(!receipt_debug.contains(opaque));
485 }
486 assert!(receipt_debug.contains("application_count"));
487 assert!(receipt_debug.contains("<redacted>"));
488
489 let page = api_visibility_reviews_for_resource(
490 &knowledge,
491 &resource(),
492 &ApiVisibilityReviewQuery::default(),
493 );
494 let review = &page.reviews()[0];
495 let review_debug = format!("{review:?}");
496 let page_debug = format!("{page:?}");
497 for debug in [&review_debug, &page_debug] {
498 for opaque in [
499 review.resource_scope().as_str(),
500 review.comparison_subject().as_str(),
501 review.relation_id().as_str(),
502 review.evidence().id().as_str(),
503 ] {
504 assert!(!debug.contains(opaque));
505 }
506 assert!(debug.contains("<redacted>"));
507 }
508 }
509
510 #[test]
511 fn resource_mismatch_fails_before_any_write() {
512 let (knowledge, rules) = installed();
513 let expected = EntityId::new("resource:another-account").unwrap();
514 let error = ingest_api_visibility_observation(
515 comparison(
516 "wrong-resource",
517 ApiVisibilityResult::Different,
518 ApiVisibilityPairKind::UiApi,
519 ),
520 &expected,
521 &knowledge,
522 &rules,
523 )
524 .unwrap_err();
525
526 assert!(matches!(
527 &error,
528 ApiObservationError::ResourceMismatch { .. }
529 ));
530 let display = error.to_string();
531 let debug = format!("{error:?}");
532 for opaque in [expected.as_str(), "resource:account-42"] {
533 assert!(!display.contains(opaque));
534 assert!(!debug.contains(opaque));
535 }
536 assert_eq!(
537 display,
538 "API visibility observation resource does not match expected resource"
539 );
540 assert!(debug.contains("<redacted>"));
541 assert!(error.committed_observation().is_none());
542 let stats = knowledge.stats();
543 assert_eq!(stats.evidence, 0);
544 assert_eq!(stats.relations, 0);
545 assert_eq!(stats.hypotheses, 0);
546 }
547
548 #[test]
549 fn post_commit_reasoning_error_carries_the_commit_receipt() {
550 let knowledge = KnowledgeBase::new();
551 let mut rules = RuleEngine::new();
552 let comparison_predicate = ApiEvidencePredicate::JSON_UI_API_DIFFERENCE.into_knowledge();
553 let unrelated = KnowledgePredicate::new("test", "unrelated").unwrap();
554 rules
555 .register(
556 ReasoningRule::new(
557 "000.invalid-calibration",
558 Expression::exists(KnowledgeLayer::Evidence, comparison_predicate),
559 HypothesisConclusion::new(
560 KnowledgePredicate::new("test", "result").unwrap(),
561 EvidenceValue::Boolean(true),
562 Probability::from_percent(10).unwrap(),
563 termivar_core::HypothesisStrength::Weak,
564 HypothesisState::Supported,
565 vec![EvidenceCalibration::new(
566 EvidenceSelector::exists(unrelated),
567 Probability::from_percent(90).unwrap(),
568 Probability::from_percent(10).unwrap(),
569 "deliberately cannot bind the matched comparison",
570 )
571 .unwrap()],
572 )
573 .unwrap(),
574 )
575 .unwrap(),
576 )
577 .unwrap();
578 let error = ingest_api_visibility_observation(
579 comparison(
580 "post-commit-error",
581 ApiVisibilityResult::Different,
582 ApiVisibilityPairKind::UiApi,
583 ),
584 &resource(),
585 &knowledge,
586 &rules,
587 )
588 .unwrap_err();
589
590 assert!(matches!(
591 error.reasoning_source(),
592 Some(RuleEngineError::MissingCalibratedEvidence { .. })
593 ));
594 let commit = error.committed_observation().unwrap();
595 assert_eq!(commit.evidence_write(), KnowledgeWrite::Inserted);
596 assert_eq!(commit.relation_write(), KnowledgeWrite::Inserted);
597 assert!(knowledge.evidence(commit.evidence_id()).is_some());
598 assert!(knowledge.relation(commit.relation_id()).is_some());
599 }
600
601 #[test]
602 fn resource_projection_is_stable_and_ignores_noncanonical_relations() {
603 let (knowledge, rules) = installed();
604 for observation in [
605 comparison(
606 "second",
607 ApiVisibilityResult::Different,
608 ApiVisibilityPairKind::AuthorizationContext,
609 ),
610 comparison(
611 "first",
612 ApiVisibilityResult::Equivalent,
613 ApiVisibilityPairKind::UiApi,
614 ),
615 ] {
616 ingest_api_visibility_observation(observation, &resource(), &knowledge, &rules)
617 .unwrap();
618 }
619
620 let unrelated = Evidence::new(
621 EntityId::new("not-a-comparison").unwrap(),
622 EvidenceKind::Custom(API_VISIBILITY_EVIDENCE_KIND.to_owned()),
623 ApiEvidencePredicate::JSON_UI_API_DIFFERENCE.into_knowledge(),
624 EvidenceValue::from(ApiVisibilityDimension::Fields),
625 EvidenceSource::new("forged", API_VISIBILITY_SOURCE_METHOD).unwrap(),
626 ConfidenceScore::MAX,
627 );
628 let unrelated_id = unrelated.id().clone();
629 let unrelated_subject = unrelated.subject().clone();
630 knowledge.insert_evidence(unrelated).unwrap();
631 knowledge
632 .upsert_relation(termivar_core::KnowledgeRelation::new(
633 unrelated_subject,
634 resource(),
635 RelationKind::Custom(API_VISIBILITY_RELATION.to_owned()),
636 ConfidenceScore::MAX,
637 unrelated_id,
638 ))
639 .unwrap();
640
641 let query = ApiVisibilityReviewQuery::default();
642 let first = api_visibility_reviews_for_resource(&knowledge, &resource(), &query);
643 let second = api_visibility_reviews_for_resource(&knowledge, &resource(), &query);
644 assert_eq!(first, second);
645 assert_eq!(first.reviews().len(), 2);
646 assert!(first
647 .reviews()
648 .windows(2)
649 .all(|pair| pair[0].relation_id() < pair[1].relation_id()));
650 assert!(first.reviews().iter().all(|review| {
651 review.evidence().subject() == review.comparison_subject()
652 && review.resource_scope() == &resource()
653 }));
654 }
655
656 #[test]
657 fn oversized_observation_provenance_is_rejected_before_commit() {
658 let knowledge = KnowledgeBase::new();
659 let rules = RuleEngine::new();
660 let observation = comparison_with_source(
661 "oversized-source",
662 ApiVisibilityResult::Different,
663 ApiVisibilityPairKind::UiApi,
664 "s".repeat(MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES + 1),
665 );
666
667 assert!(matches!(
668 ingest_api_visibility_observation(observation, &resource(), &knowledge, &rules),
669 Err(ApiObservationError::ObservationLimitExceeded {
670 field: "source.component",
671 ..
672 })
673 ));
674 let stats = knowledge.stats();
675 assert_eq!(stats.evidence, 0);
676 assert_eq!(stats.relations, 0);
677 }
678
679 #[test]
680 fn projection_rejects_oversized_records_from_direct_store_writers() {
681 let knowledge = KnowledgeBase::new();
682 let observation = comparison_with_source(
683 "direct-oversized-source",
684 ApiVisibilityResult::Different,
685 ApiVisibilityPairKind::UiApi,
686 "s".repeat(MAX_API_VISIBILITY_SOURCE_COMPONENT_BYTES + 1),
687 );
688 let (evidence, relation) = observation.into_parts();
689 knowledge
690 .insert_evidence_with_relation(evidence, relation)
691 .unwrap();
692
693 let page = api_visibility_reviews_for_resource(
694 &knowledge,
695 &resource(),
696 &ApiVisibilityReviewQuery::default(),
697 );
698 assert_eq!(page.scanned_relations(), 1);
699 assert!(page.reviews().is_empty());
700 }
701
702 #[test]
703 fn projection_does_not_clone_an_oversized_boundary_rationale() {
704 let (knowledge, rules) = installed();
705 let receipt = ingest_api_visibility_observation(
706 comparison(
707 "oversized-rationale",
708 ApiVisibilityResult::Different,
709 ApiVisibilityPairKind::UiApi,
710 ),
711 &resource(),
712 &knowledge,
713 &rules,
714 )
715 .unwrap();
716 let evidence = knowledge.evidence(receipt.commit().evidence_id()).unwrap();
717 let mut hypothesis = Hypothesis::with_id(
718 hypothesis_id_for_rule(UI_API_BOUNDARY_RULE, evidence.subject()),
719 evidence.subject().clone(),
720 ApiKnowledgePredicate::VISIBILITY_BOUNDARY.into_knowledge(),
721 EvidenceValue::from(ApiVisibilityBoundaryKind::UiApi),
722 Probability::from_percent(10).unwrap(),
723 )
724 .unwrap();
725 hypothesis
726 .observe(
727 BayesianEvidence::new(
728 evidence.id().clone(),
729 Probability::from_percent(98).unwrap(),
730 Probability::from_percent(2).unwrap(),
731 "r".repeat(MAX_API_VISIBILITY_REVIEW_RATIONALE_BYTES + 1),
732 )
733 .unwrap(),
734 )
735 .unwrap();
736 hypothesis.set_strength(HypothesisStrength::Strong);
737 hypothesis.set_state(HypothesisState::Supported);
738 knowledge.upsert_hypothesis(hypothesis).unwrap();
739
740 let page = api_visibility_reviews_for_resource(
741 &knowledge,
742 &resource(),
743 &ApiVisibilityReviewQuery::default(),
744 );
745 assert_eq!(page.reviews().len(), 1);
746 assert!(page.reviews()[0].boundary_hypotheses().is_empty());
747 }
748
749 #[test]
750 fn review_query_is_strict_and_enforces_the_compiled_ceiling() {
751 assert!(matches!(
752 ApiVisibilityReviewQuery::new(0),
753 Err(ApiObservationError::ZeroReviewScanLimit)
754 ));
755 assert!(matches!(
756 ApiVisibilityReviewQuery::new(HARD_MAX_API_VISIBILITY_REVIEW_SCAN_LIMIT + 1),
757 Err(ApiObservationError::ReviewScanLimitExceeded { .. })
758 ));
759 assert!(
760 serde_json::from_value::<ApiVisibilityReviewQuery>(serde_json::json!({
761 "scan_limit": 0
762 }))
763 .is_err()
764 );
765 assert!(
766 serde_json::from_value::<ApiVisibilityReviewQuery>(serde_json::json!({
767 "scan_limit": 1,
768 "unexpected": true
769 }))
770 .is_err()
771 );
772 let oversized_cursor =
773 RelationId::parse("r".repeat(MAX_KNOWLEDGE_RELATION_ID_BYTES + 1)).unwrap();
774 assert!(matches!(
775 ApiVisibilityReviewQuery::new(1)
776 .unwrap()
777 .after_relation_id(oversized_cursor.clone()),
778 Err(ApiObservationError::ReviewCursorTooLong { .. })
779 ));
780 assert!(
781 serde_json::from_value::<ApiVisibilityReviewQuery>(serde_json::json!({
782 "after_relation_id": oversized_cursor,
783 "scan_limit": 1
784 }))
785 .is_err()
786 );
787
788 let cursor = RelationId::parse("relation:cursor").unwrap();
789 let query = ApiVisibilityReviewQuery::new(7)
790 .unwrap()
791 .after_relation_id(cursor.clone())
792 .unwrap();
793 let decoded: ApiVisibilityReviewQuery =
794 serde_json::from_value(serde_json::to_value(&query).unwrap()).unwrap();
795 assert_eq!(decoded, query);
796 assert_eq!(decoded.after(), Some(&cursor));
797 assert_eq!(decoded.scan_limit(), 7);
798 let debug = format!("{decoded:?}");
799 assert!(!debug.contains(cursor.as_str()));
800 assert!(debug.contains("<redacted>"));
801 }
802
803 #[test]
804 fn resource_bound_cursor_round_trips_and_paginates_same_resource() {
805 let (knowledge, rules) = installed();
806 for id in ["cursor-page-a", "cursor-page-b", "cursor-page-c"] {
807 ingest_api_visibility_observation(
808 comparison(
809 id,
810 ApiVisibilityResult::Different,
811 ApiVisibilityPairKind::UiApi,
812 ),
813 &resource(),
814 &knowledge,
815 &rules,
816 )
817 .unwrap();
818 }
819
820 let first =
821 api_visibility_reviews_for_resource_v2(&knowledge, &resource(), None, 1).unwrap();
822 assert_eq!(first.scanned_relations(), 1);
823 assert_eq!(first.reviews().len(), 1);
824 let cursor = first.next_cursor().unwrap().unwrap();
825 let decoded: ApiVisibilityReviewCursor =
826 serde_json::from_value(serde_json::to_value(&cursor).unwrap()).unwrap();
827 assert_eq!(decoded, cursor);
828 assert_eq!(decoded.version(), 2);
829
830 let second =
831 api_visibility_reviews_for_resource_v2(&knowledge, &resource(), Some(&decoded), 1)
832 .unwrap();
833 assert_eq!(second.scanned_relations(), 1);
834 assert_eq!(second.reviews().len(), 1);
835 assert_ne!(
836 first.reviews()[0].relation_id(),
837 second.reviews()[0].relation_id()
838 );
839 }
840
841 #[test]
842 fn resource_bound_cursor_rejects_cross_resource_replay_without_leaking_ids() {
843 let source = resource();
844 let target = EntityId::new("resource:another-sensitive-account").unwrap();
845 let relation = RelationId::parse("relation:sensitive-position").unwrap();
846 let cursor = ApiVisibilityReviewCursor::new(&source, relation.clone()).unwrap();
847 let error = api_visibility_reviews_for_resource_v2(
848 &KnowledgeBase::new(),
849 &target,
850 Some(&cursor),
851 1,
852 )
853 .unwrap_err();
854
855 assert!(matches!(
856 error,
857 ApiObservationError::ResourceBoundReviewCursorMismatch
858 ));
859 for output in [error.to_string(), format!("{error:?}")] {
860 assert!(!output.contains(source.as_str()));
861 assert!(!output.contains(target.as_str()));
862 assert!(!output.contains(relation.as_str()));
863 assert!(!output.contains(cursor.as_str()));
864 }
865 }
866
867 #[test]
868 fn resource_bound_cursor_rejects_malformed_versioned_and_oversized_tokens() {
869 assert!(matches!(
870 ApiVisibilityReviewCursor::parse("not-a-review-cursor"),
871 Err(ApiObservationError::InvalidResourceBoundReviewCursor { .. })
872 ));
873 assert!(matches!(
874 ApiVisibilityReviewCursor::parse("venom-api-review-v3:payload"),
875 Err(ApiObservationError::UnsupportedResourceBoundReviewCursorVersion)
876 ));
877 assert!(matches!(
878 ApiVisibilityReviewCursor::parse(
879 "x".repeat(MAX_API_VISIBILITY_REVIEW_CURSOR_BYTES + 1)
880 ),
881 Err(ApiObservationError::ResourceBoundReviewCursorTooLong { .. })
882 ));
883
884 let cursor = ApiVisibilityReviewCursor::new(
885 &resource(),
886 RelationId::parse("relation:cursor").unwrap(),
887 )
888 .unwrap();
889 let mut uppercase = cursor.as_str().to_owned();
890 uppercase.pop();
891 uppercase.push('A');
892 assert!(matches!(
893 ApiVisibilityReviewCursor::parse(uppercase),
894 Err(ApiObservationError::InvalidResourceBoundReviewCursor { .. })
895 ));
896 let mut odd = cursor.as_str().to_owned();
897 odd.pop();
898 assert!(
899 serde_json::from_value::<ApiVisibilityReviewCursor>(serde_json::json!(odd)).is_err()
900 );
901 }
902
903 #[test]
904 fn resource_bound_cursor_serialization_is_transparent_and_debug_is_redacted() {
905 let resource = resource();
906 let relation = RelationId::parse("relation:sensitive-cursor").unwrap();
907 let cursor = ApiVisibilityReviewCursor::new(&resource, relation.clone()).unwrap();
908
909 assert_eq!(
910 serde_json::to_value(&cursor).unwrap(),
911 serde_json::Value::String(cursor.as_str().to_owned())
912 );
913 assert!(!cursor.as_str().contains(resource.as_str()));
914 assert!(!cursor.as_str().contains(relation.as_str()));
915 for output in [format!("{cursor:?}"), cursor.to_string()] {
916 assert!(output.contains("<redacted>"));
917 assert!(!output.contains(cursor.as_str()));
918 assert!(!output.contains(resource.as_str()));
919 assert!(!output.contains(relation.as_str()));
920 }
921 }
922
923 #[test]
924 fn legacy_review_query_wire_shape_remains_unchanged() {
925 let cursor = RelationId::parse("relation:legacy-cursor").unwrap();
926 let query = ApiVisibilityReviewQuery::new(7)
927 .unwrap()
928 .after_relation_id(cursor)
929 .unwrap();
930
931 assert_eq!(
932 serde_json::to_value(query).unwrap(),
933 serde_json::json!({
934 "after_relation_id": "relation:legacy-cursor",
935 "scan_limit": 7
936 })
937 );
938 }
939
940 #[test]
941 fn review_pages_advance_by_the_last_scanned_relation() {
942 let (knowledge, rules) = installed();
943 for id in ["page-a", "page-b", "page-c"] {
944 ingest_api_visibility_observation(
945 comparison(
946 id,
947 ApiVisibilityResult::Different,
948 ApiVisibilityPairKind::UiApi,
949 ),
950 &resource(),
951 &knowledge,
952 &rules,
953 )
954 .unwrap();
955 }
956
957 let mut query = ApiVisibilityReviewQuery::new(1).unwrap();
958 let mut seen = Vec::new();
959 loop {
960 let page = api_visibility_reviews_for_resource(&knowledge, &resource(), &query);
961 assert_eq!(page.scanned_relations(), 1);
962 assert_eq!(page.reviews().len(), 1);
963 seen.push(page.reviews()[0].relation_id().clone());
964 let Some(cursor) = page.next_after_relation_id().cloned() else {
965 break;
966 };
967 query = ApiVisibilityReviewQuery::new(1)
968 .unwrap()
969 .after_relation_id(cursor)
970 .unwrap();
971 }
972
973 assert_eq!(seen.len(), 3);
974 assert!(seen.windows(2).all(|pair| pair[0] < pair[1]));
975 }
976
977 #[test]
978 fn rejected_relations_consume_the_page_scan_budget() {
979 let (knowledge, rules) = installed();
980 let invalid = Evidence::new(
981 EntityId::new("invalid-comparison").unwrap(),
982 EvidenceKind::Custom(API_VISIBILITY_EVIDENCE_KIND.to_owned()),
983 ApiEvidencePredicate::JSON_UI_API_DIFFERENCE.into_knowledge(),
984 EvidenceValue::from(ApiVisibilityDimension::Fields),
985 EvidenceSource::new("untrusted", API_VISIBILITY_SOURCE_METHOD).unwrap(),
986 ConfidenceScore::MAX,
987 );
988 let invalid_id = invalid.id().clone();
989 let invalid_subject = invalid.subject().clone();
990 knowledge.insert_evidence(invalid).unwrap();
991 knowledge
992 .upsert_relation(termivar_core::KnowledgeRelation::with_id(
993 RelationId::parse("000-invalid-relation").unwrap(),
994 invalid_subject,
995 resource(),
996 RelationKind::Custom(API_VISIBILITY_RELATION.to_owned()),
997 ConfidenceScore::MAX,
998 invalid_id,
999 ))
1000 .unwrap();
1001 ingest_api_visibility_observation(
1002 comparison(
1003 "valid-after-invalid",
1004 ApiVisibilityResult::Different,
1005 ApiVisibilityPairKind::UiApi,
1006 ),
1007 &resource(),
1008 &knowledge,
1009 &rules,
1010 )
1011 .unwrap();
1012
1013 let first_query = ApiVisibilityReviewQuery::new(1).unwrap();
1014 let first = api_visibility_reviews_for_resource(&knowledge, &resource(), &first_query);
1015 assert_eq!(first.scanned_relations(), 1);
1016 assert!(first.reviews().is_empty());
1017 let cursor = first.next_after_relation_id().cloned().unwrap();
1018 assert_eq!(cursor.as_str(), "000-invalid-relation");
1019 let page_debug = format!("{first:?}");
1020 assert!(!page_debug.contains(cursor.as_str()));
1021 assert!(page_debug.contains("<redacted>"));
1022
1023 let second_query = ApiVisibilityReviewQuery::new(1)
1024 .unwrap()
1025 .after_relation_id(cursor)
1026 .unwrap();
1027 let second = api_visibility_reviews_for_resource(&knowledge, &resource(), &second_query);
1028 assert_eq!(second.scanned_relations(), 1);
1029 assert_eq!(second.reviews().len(), 1);
1030 assert!(second.next_after_relation_id().is_none());
1031 }
1032
1033 #[test]
1034 fn oversized_relation_is_rejected_before_it_can_reach_review_projection() {
1035 let knowledge = KnowledgeBase::new();
1036 let relation = termivar_core::KnowledgeRelation::with_id(
1037 RelationId::parse("r".repeat(crate::knowledge::MAX_KNOWLEDGE_RELATION_ID_BYTES + 1))
1038 .unwrap(),
1039 EntityId::new("comparison:oversized-relation").unwrap(),
1040 resource(),
1041 RelationKind::Custom(API_VISIBILITY_RELATION.to_owned()),
1042 ConfidenceScore::MAX,
1043 EvidenceId::parse("evidence:oversized-relation").unwrap(),
1044 );
1045
1046 assert!(matches!(
1047 knowledge.upsert_relation(relation),
1048 Err(KnowledgeBaseError::RelationLimitExceeded { field: "id", .. })
1049 ));
1050 let page = api_visibility_reviews_for_resource(
1051 &knowledge,
1052 &resource(),
1053 &ApiVisibilityReviewQuery::default(),
1054 );
1055 assert_eq!(page.scanned_relations(), 0);
1056 assert!(page.reviews().is_empty());
1057 assert!(page.next_after_relation_id().is_none());
1058 }
1059}