1use serde::{Deserialize, Deserializer, Serialize};
8use sha2::{Digest, Sha256};
9use std::{
10 fmt,
11 time::{SystemTime, UNIX_EPOCH},
12};
13use thiserror::Error;
14
15use crate::{
16 ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
17 KnowledgePredicate, KnowledgeRelation, ReasoningModelError, RelationId, RelationKind,
18};
19
20const MAX_OPAQUE_CONTEXT_BYTES: usize = 256;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct PredicateDescriptor {
29 namespace: &'static str,
30 name: &'static str,
31 dotted: &'static str,
32}
33
34impl PredicateDescriptor {
35 const fn new(namespace: &'static str, name: &'static str, dotted: &'static str) -> Self {
36 Self {
37 namespace,
38 name,
39 dotted,
40 }
41 }
42
43 pub const fn namespace(self) -> &'static str {
45 self.namespace
46 }
47
48 pub const fn name(self) -> &'static str {
50 self.name
51 }
52
53 pub const fn dotted(self) -> &'static str {
55 self.dotted
56 }
57
58 pub fn into_knowledge(self) -> KnowledgePredicate {
60 KnowledgePredicate::new(self.namespace, self.name)
61 .expect("static predicate descriptors contain non-empty components")
62 }
63}
64
65impl From<PredicateDescriptor> for KnowledgePredicate {
66 fn from(value: PredicateDescriptor) -> Self {
67 value.into_knowledge()
68 }
69}
70
71pub struct HttpEvidencePredicate;
76
77impl HttpEvidencePredicate {
78 pub const REQUEST_METHOD: PredicateDescriptor =
80 PredicateDescriptor::new("http.request", "method", "http.request.method");
81 pub const REQUEST_URL: PredicateDescriptor =
83 PredicateDescriptor::new("http.request", "url", "http.request.url");
84 pub const REQUEST_PATH_SEGMENT: PredicateDescriptor =
86 PredicateDescriptor::new("http.request", "path-segment", "http.request.path-segment");
87 pub const RESPONSE_STATUS: PredicateDescriptor =
89 PredicateDescriptor::new("http.response", "status", "http.response.status");
90 pub const RESPONSE_FINAL_URL: PredicateDescriptor =
92 PredicateDescriptor::new("http.response", "final-url", "http.response.final-url");
93 pub const RESPONSE_VERSION: PredicateDescriptor =
95 PredicateDescriptor::new("http.response", "version", "http.response.version");
96 pub const RESPONSE_MEDIA_TYPE: PredicateDescriptor =
98 PredicateDescriptor::new("http.response", "media-type", "http.response.media-type");
99 pub const RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE: PredicateDescriptor = PredicateDescriptor::new(
101 "http.response",
102 "media-type-json-compatible",
103 "http.response.media-type-json-compatible",
104 );
105 pub const RESPONSE_BODY_BYTES_OBSERVED: PredicateDescriptor = PredicateDescriptor::new(
107 "http.response",
108 "body-bytes-observed",
109 "http.response.body-bytes-observed",
110 );
111 pub const RESPONSE_BODY_TRUNCATED: PredicateDescriptor = PredicateDescriptor::new(
113 "http.response",
114 "body-truncated",
115 "http.response.body-truncated",
116 );
117 pub const RESPONSE_BODY_SHA256: PredicateDescriptor =
119 PredicateDescriptor::new("http.response", "body-sha256", "http.response.body-sha256");
120 pub const RESPONSE_BODY_SAMPLE: PredicateDescriptor =
122 PredicateDescriptor::new("http.response", "body-sample", "http.response.body-sample");
123 pub const RESPONSE_FORM_CONTROL_NAMES: PredicateDescriptor = PredicateDescriptor::new(
131 "http.response",
132 "form-control-names",
133 "http.response.form-control-names",
134 );
135 pub const TIMING_TTFB_MS: PredicateDescriptor =
137 PredicateDescriptor::new("http.timing", "ttfb-ms", "http.timing.ttfb-ms");
138 pub const TIMING_TOTAL_MS: PredicateDescriptor =
140 PredicateDescriptor::new("http.timing", "total-ms", "http.timing.total-ms");
141 pub const COOKIE_NAME: PredicateDescriptor =
143 PredicateDescriptor::new("http.cookie", "name", "http.cookie.name");
144 pub const HEADER_ALLOW: PredicateDescriptor =
146 PredicateDescriptor::new("http.header", "allow", "http.header.allow");
147 pub const HEADER_CONTENT_TYPE: PredicateDescriptor =
149 PredicateDescriptor::new("http.header", "content-type", "http.header.content-type");
150 pub const HEADER_SERVER: PredicateDescriptor =
152 PredicateDescriptor::new("http.header", "server", "http.header.server");
153 pub const HEADER_WWW_AUTHENTICATE: PredicateDescriptor = PredicateDescriptor::new(
155 "http.header",
156 "www-authenticate",
157 "http.header.www-authenticate",
158 );
159 pub const HEADER_X_POWERED_BY: PredicateDescriptor =
161 PredicateDescriptor::new("http.header", "x-powered-by", "http.header.x-powered-by");
162 pub const RATE_LIMIT_DETECTED: PredicateDescriptor =
164 PredicateDescriptor::new("http.rate-limit", "detected", "http.rate-limit.detected");
165 pub const RATE_LIMIT_ADVERTISED: PredicateDescriptor = PredicateDescriptor::new(
167 "http.rate-limit",
168 "advertised",
169 "http.rate-limit.advertised",
170 );
171 pub const RATE_LIMIT_RETRY_AFTER: PredicateDescriptor = PredicateDescriptor::new(
173 "http.rate-limit",
174 "retry-after",
175 "http.rate-limit.retry-after",
176 );
177 pub const RATE_LIMIT_LIMIT: PredicateDescriptor =
179 PredicateDescriptor::new("http.rate-limit", "limit", "http.rate-limit.limit");
180 pub const RATE_LIMIT_REMAINING: PredicateDescriptor =
182 PredicateDescriptor::new("http.rate-limit", "remaining", "http.rate-limit.remaining");
183 pub const RATE_LIMIT_RESET: PredicateDescriptor =
185 PredicateDescriptor::new("http.rate-limit", "reset", "http.rate-limit.reset");
186
187 pub fn response_header(
192 normalized_name: impl Into<String>,
193 ) -> Result<KnowledgePredicate, ReasoningModelError> {
194 KnowledgePredicate::new("http.header", normalized_name)
195 }
196
197 pub const fn fixed() -> &'static [PredicateDescriptor] {
199 &[
200 Self::REQUEST_METHOD,
201 Self::REQUEST_URL,
202 Self::REQUEST_PATH_SEGMENT,
203 Self::RESPONSE_STATUS,
204 Self::RESPONSE_FINAL_URL,
205 Self::RESPONSE_VERSION,
206 Self::RESPONSE_MEDIA_TYPE,
207 Self::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE,
208 Self::RESPONSE_BODY_BYTES_OBSERVED,
209 Self::RESPONSE_BODY_TRUNCATED,
210 Self::RESPONSE_BODY_SHA256,
211 Self::RESPONSE_BODY_SAMPLE,
212 Self::RESPONSE_FORM_CONTROL_NAMES,
213 Self::TIMING_TTFB_MS,
214 Self::TIMING_TOTAL_MS,
215 Self::COOKIE_NAME,
216 Self::HEADER_ALLOW,
217 Self::HEADER_CONTENT_TYPE,
218 Self::HEADER_SERVER,
219 Self::HEADER_WWW_AUTHENTICATE,
220 Self::HEADER_X_POWERED_BY,
221 Self::RATE_LIMIT_DETECTED,
222 Self::RATE_LIMIT_ADVERTISED,
223 Self::RATE_LIMIT_RETRY_AFTER,
224 Self::RATE_LIMIT_LIMIT,
225 Self::RATE_LIMIT_REMAINING,
226 Self::RATE_LIMIT_RESET,
227 ]
228 }
229}
230
231pub struct WebDiscoveryEvidencePredicate;
238
239impl WebDiscoveryEvidencePredicate {
240 pub const DOCUMENT_PROJECTED: PredicateDescriptor = PredicateDescriptor::new(
243 "web.discovery",
244 "document-projected",
245 "web.discovery.document-projected",
246 );
247 pub const DOCUMENT_BODY_INCOMPLETE: PredicateDescriptor = PredicateDescriptor::new(
250 "web.discovery",
251 "document-body-incomplete",
252 "web.discovery.document-body-incomplete",
253 );
254 pub const DOCUMENT_PARTIAL_REPRESENTATION: PredicateDescriptor = PredicateDescriptor::new(
257 "web.discovery",
258 "document-partial-representation",
259 "web.discovery.document-partial-representation",
260 );
261 pub const DOCUMENT_INVALID_UTF8: PredicateDescriptor = PredicateDescriptor::new(
264 "web.discovery",
265 "document-invalid-utf8",
266 "web.discovery.document-invalid-utf8",
267 );
268 pub const DOCUMENT_ROUTE_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
270 "web.discovery",
271 "document-route-limit-reached",
272 "web.discovery.document-route-limit-reached",
273 );
274 pub const DOCUMENT_FORM_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
276 "web.discovery",
277 "document-form-limit-reached",
278 "web.discovery.document-form-limit-reached",
279 );
280 pub const DOCUMENT_CONTROL_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
282 "web.discovery",
283 "document-control-limit-reached",
284 "web.discovery.document-control-limit-reached",
285 );
286 pub const DOCUMENT_QUERY_NAME_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
289 "web.discovery",
290 "document-query-name-limit-reached",
291 "web.discovery.document-query-name-limit-reached",
292 );
293 pub const DOCUMENT_URL_BYTE_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
296 "web.discovery",
297 "document-url-byte-limit-reached",
298 "web.discovery.document-url-byte-limit-reached",
299 );
300 pub const ASSESSMENT_SUBJECT_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
303 "web.discovery",
304 "assessment-subject-limit-reached",
305 "web.discovery.assessment-subject-limit-reached",
306 );
307 pub const ASSESSMENT_DEPTH_LIMIT_REACHED: PredicateDescriptor = PredicateDescriptor::new(
310 "web.discovery",
311 "assessment-depth-limit-reached",
312 "web.discovery.assessment-depth-limit-reached",
313 );
314 pub const ASSESSMENT_RETAINED_URL_BYTE_LIMIT_REACHED: PredicateDescriptor =
317 PredicateDescriptor::new(
318 "web.discovery",
319 "assessment-retained-url-byte-limit-reached",
320 "web.discovery.assessment-retained-url-byte-limit-reached",
321 );
322 pub const DOCUMENT_OUTSIDE_ORIGIN_REFERENCE_COUNT: PredicateDescriptor =
325 PredicateDescriptor::new(
326 "web.discovery",
327 "document-outside-origin-reference-count",
328 "web.discovery.document-outside-origin-reference-count",
329 );
330 pub const GET_ROUTE: PredicateDescriptor =
332 PredicateDescriptor::new("web.discovery", "get-route", "web.discovery.get-route");
333 pub const HEAD_ROUTE: PredicateDescriptor =
335 PredicateDescriptor::new("web.discovery", "head-route", "web.discovery.head-route");
336 pub const ROUTE_QUERY_PARAMETER_NAMES: PredicateDescriptor = PredicateDescriptor::new(
339 "web.discovery",
340 "route-query-parameter-names",
341 "web.discovery.route-query-parameter-names",
342 );
343 pub const GET_FORM_ACTION: PredicateDescriptor = PredicateDescriptor::new(
345 "web.discovery",
346 "get-form-action",
347 "web.discovery.get-form-action",
348 );
349 pub const POST_FORM_ACTION: PredicateDescriptor = PredicateDescriptor::new(
352 "web.discovery",
353 "post-form-action",
354 "web.discovery.post-form-action",
355 );
356 pub const DIALOG_FORM_ACTION: PredicateDescriptor = PredicateDescriptor::new(
359 "web.discovery",
360 "dialog-form-action",
361 "web.discovery.dialog-form-action",
362 );
363 pub const FORM_QUERY_PARAMETER_NAMES: PredicateDescriptor = PredicateDescriptor::new(
365 "web.discovery",
366 "form-query-parameter-names",
367 "web.discovery.form-query-parameter-names",
368 );
369 pub const FORM_CONTROL_NAMES: PredicateDescriptor = PredicateDescriptor::new(
371 "web.discovery",
372 "form-control-names",
373 "web.discovery.form-control-names",
374 );
375
376 pub const fn fixed() -> &'static [PredicateDescriptor] {
378 &[
379 Self::DOCUMENT_PROJECTED,
380 Self::DOCUMENT_BODY_INCOMPLETE,
381 Self::DOCUMENT_PARTIAL_REPRESENTATION,
382 Self::DOCUMENT_INVALID_UTF8,
383 Self::DOCUMENT_ROUTE_LIMIT_REACHED,
384 Self::DOCUMENT_FORM_LIMIT_REACHED,
385 Self::DOCUMENT_CONTROL_LIMIT_REACHED,
386 Self::DOCUMENT_QUERY_NAME_LIMIT_REACHED,
387 Self::DOCUMENT_URL_BYTE_LIMIT_REACHED,
388 Self::ASSESSMENT_SUBJECT_LIMIT_REACHED,
389 Self::ASSESSMENT_DEPTH_LIMIT_REACHED,
390 Self::ASSESSMENT_RETAINED_URL_BYTE_LIMIT_REACHED,
391 Self::DOCUMENT_OUTSIDE_ORIGIN_REFERENCE_COUNT,
392 Self::GET_ROUTE,
393 Self::HEAD_ROUTE,
394 Self::ROUTE_QUERY_PARAMETER_NAMES,
395 Self::GET_FORM_ACTION,
396 Self::POST_FORM_ACTION,
397 Self::DIALOG_FORM_ACTION,
398 Self::FORM_QUERY_PARAMETER_NAMES,
399 Self::FORM_CONTROL_NAMES,
400 ]
401 }
402}
403
404pub struct WebKnowledgePredicate;
406
407impl WebKnowledgePredicate {
408 pub const TECHNOLOGY_WEB_SERVER: PredicateDescriptor =
410 PredicateDescriptor::new("technology", "web-server", "technology.web-server");
411 pub const TECHNOLOGY_LANGUAGE: PredicateDescriptor =
413 PredicateDescriptor::new("technology", "language", "technology.language");
414 pub const TECHNOLOGY_FRAMEWORK: PredicateDescriptor =
416 PredicateDescriptor::new("technology", "framework", "technology.framework");
417 pub const TECHNOLOGY_UI_FRAMEWORK: PredicateDescriptor =
419 PredicateDescriptor::new("technology", "ui-framework", "technology.ui-framework");
420 pub const AUTHENTICATION_MECHANISM: PredicateDescriptor =
422 PredicateDescriptor::new("authentication", "mechanism", "authentication.mechanism");
423 pub const FORM_CONVENTION: PredicateDescriptor =
431 PredicateDescriptor::new("web.form", "convention", "web.form.convention");
432}
433
434pub struct ApiEvidencePredicate;
436
437impl ApiEvidencePredicate {
438 pub const JSON_UI_API_DIFFERENCE: PredicateDescriptor = PredicateDescriptor::new(
440 "api.visibility.json.ui-api",
441 "difference",
442 "api.visibility.json.ui-api.difference",
443 );
444 pub const JSON_UI_API_EQUIVALENT: PredicateDescriptor = PredicateDescriptor::new(
446 "api.visibility.json.ui-api",
447 "equivalent",
448 "api.visibility.json.ui-api.equivalent",
449 );
450 pub const JSON_AUTHORIZATION_CONTEXT_DIFFERENCE: PredicateDescriptor = PredicateDescriptor::new(
452 "api.visibility.json.authorization-context",
453 "difference",
454 "api.visibility.json.authorization-context.difference",
455 );
456 pub const JSON_AUTHORIZATION_CONTEXT_EQUIVALENT: PredicateDescriptor = PredicateDescriptor::new(
458 "api.visibility.json.authorization-context",
459 "equivalent",
460 "api.visibility.json.authorization-context.equivalent",
461 );
462 pub const GRAPHQL_UI_API_DIFFERENCE: PredicateDescriptor = PredicateDescriptor::new(
464 "api.visibility.graphql.ui-api",
465 "difference",
466 "api.visibility.graphql.ui-api.difference",
467 );
468 pub const GRAPHQL_UI_API_EQUIVALENT: PredicateDescriptor = PredicateDescriptor::new(
470 "api.visibility.graphql.ui-api",
471 "equivalent",
472 "api.visibility.graphql.ui-api.equivalent",
473 );
474 pub const GRAPHQL_AUTHORIZATION_CONTEXT_DIFFERENCE: PredicateDescriptor =
476 PredicateDescriptor::new(
477 "api.visibility.graphql.authorization-context",
478 "difference",
479 "api.visibility.graphql.authorization-context.difference",
480 );
481 pub const GRAPHQL_AUTHORIZATION_CONTEXT_EQUIVALENT: PredicateDescriptor =
483 PredicateDescriptor::new(
484 "api.visibility.graphql.authorization-context",
485 "equivalent",
486 "api.visibility.graphql.authorization-context.equivalent",
487 );
488
489 pub const fn visibility(
491 surface: ApiSurfaceKind,
492 pair: ApiVisibilityPairKind,
493 result: ApiVisibilityResult,
494 ) -> PredicateDescriptor {
495 match (surface, pair, result) {
496 (
497 ApiSurfaceKind::JsonHttp,
498 ApiVisibilityPairKind::UiApi,
499 ApiVisibilityResult::Different,
500 ) => Self::JSON_UI_API_DIFFERENCE,
501 (
502 ApiSurfaceKind::JsonHttp,
503 ApiVisibilityPairKind::UiApi,
504 ApiVisibilityResult::Equivalent,
505 ) => Self::JSON_UI_API_EQUIVALENT,
506 (
507 ApiSurfaceKind::JsonHttp,
508 ApiVisibilityPairKind::AuthorizationContext,
509 ApiVisibilityResult::Different,
510 ) => Self::JSON_AUTHORIZATION_CONTEXT_DIFFERENCE,
511 (
512 ApiSurfaceKind::JsonHttp,
513 ApiVisibilityPairKind::AuthorizationContext,
514 ApiVisibilityResult::Equivalent,
515 ) => Self::JSON_AUTHORIZATION_CONTEXT_EQUIVALENT,
516 (
517 ApiSurfaceKind::GraphQl,
518 ApiVisibilityPairKind::UiApi,
519 ApiVisibilityResult::Different,
520 ) => Self::GRAPHQL_UI_API_DIFFERENCE,
521 (
522 ApiSurfaceKind::GraphQl,
523 ApiVisibilityPairKind::UiApi,
524 ApiVisibilityResult::Equivalent,
525 ) => Self::GRAPHQL_UI_API_EQUIVALENT,
526 (
527 ApiSurfaceKind::GraphQl,
528 ApiVisibilityPairKind::AuthorizationContext,
529 ApiVisibilityResult::Different,
530 ) => Self::GRAPHQL_AUTHORIZATION_CONTEXT_DIFFERENCE,
531 (
532 ApiSurfaceKind::GraphQl,
533 ApiVisibilityPairKind::AuthorizationContext,
534 ApiVisibilityResult::Equivalent,
535 ) => Self::GRAPHQL_AUTHORIZATION_CONTEXT_EQUIVALENT,
536 }
537 }
538}
539
540pub struct ApiKnowledgePredicate;
542
543impl ApiKnowledgePredicate {
544 pub const RESPONSE_FORMAT: PredicateDescriptor =
546 PredicateDescriptor::new("api", "response-format", "api.response-format");
547 pub const SURFACE_KIND: PredicateDescriptor =
549 PredicateDescriptor::new("api.surface", "kind", "api.surface.kind");
550 pub const VISIBILITY_BOUNDARY: PredicateDescriptor =
552 PredicateDescriptor::new("api.visibility", "boundary", "api.visibility.boundary");
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(rename_all = "kebab-case")]
558#[non_exhaustive]
559pub enum ApiResponseFormat {
560 Json,
562}
563
564impl ApiResponseFormat {
565 pub const fn as_str(self) -> &'static str {
567 match self {
568 Self::Json => "json",
569 }
570 }
571}
572
573impl From<ApiResponseFormat> for EvidenceValue {
574 fn from(value: ApiResponseFormat) -> Self {
575 Self::Text(value.as_str().to_owned())
576 }
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "kebab-case")]
582#[non_exhaustive]
583pub enum ApiSurfaceKind {
584 JsonHttp,
586 #[serde(rename = "graphql")]
588 GraphQl,
589}
590
591impl ApiSurfaceKind {
592 pub const fn as_str(self) -> &'static str {
594 match self {
595 Self::JsonHttp => "json-http-api",
596 Self::GraphQl => "graphql-api",
597 }
598 }
599}
600
601impl From<ApiSurfaceKind> for EvidenceValue {
602 fn from(value: ApiSurfaceKind) -> Self {
603 Self::Text(value.as_str().to_owned())
604 }
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
609#[serde(rename_all = "kebab-case")]
610#[non_exhaustive]
611pub enum ApiVisibilityPairKind {
612 UiApi,
614 AuthorizationContext,
616}
617
618impl ApiVisibilityPairKind {
619 pub const fn as_str(self) -> &'static str {
621 match self {
622 Self::UiApi => "ui-api",
623 Self::AuthorizationContext => "authorization-context",
624 }
625 }
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(rename_all = "kebab-case")]
631#[non_exhaustive]
632pub enum ApiVisibilityResult {
633 Different,
635 Equivalent,
637}
638
639impl ApiVisibilityResult {
640 pub const fn as_str(self) -> &'static str {
642 match self {
643 Self::Different => "different",
644 Self::Equivalent => "equivalent",
645 }
646 }
647}
648
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
651#[serde(rename_all = "kebab-case")]
652#[non_exhaustive]
653pub enum ApiVisibilityDimension {
654 Resources,
656 Fields,
658 Status,
660}
661
662impl ApiVisibilityDimension {
663 pub const fn as_str(self) -> &'static str {
665 match self {
666 Self::Resources => "resources",
667 Self::Fields => "fields",
668 Self::Status => "status",
669 }
670 }
671
672 pub const fn all() -> [Self; 3] {
674 [Self::Resources, Self::Fields, Self::Status]
675 }
676}
677
678impl From<ApiVisibilityDimension> for EvidenceValue {
679 fn from(value: ApiVisibilityDimension) -> Self {
680 Self::Text(value.as_str().to_owned())
681 }
682}
683
684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(rename_all = "kebab-case")]
687#[non_exhaustive]
688pub enum ApiVisibilityBoundaryKind {
689 UiApi,
691 AuthorizationContext,
693}
694
695impl ApiVisibilityBoundaryKind {
696 pub const fn as_str(self) -> &'static str {
698 match self {
699 Self::UiApi => "ui-api-visibility-boundary",
700 Self::AuthorizationContext => "authorization-context-visibility-boundary",
701 }
702 }
703}
704
705impl From<ApiVisibilityBoundaryKind> for EvidenceValue {
706 fn from(value: ApiVisibilityBoundaryKind) -> Self {
707 Self::Text(value.as_str().to_owned())
708 }
709}
710
711#[derive(Debug, Clone, PartialEq, Eq, Error)]
713#[non_exhaustive]
714pub enum ApiVocabularyError {
715 #[error(transparent)]
717 Reasoning(#[from] ReasoningModelError),
718
719 #[error("{field} must not be empty")]
721 EmptyContext { field: &'static str },
722
723 #[error("{field} exceeds the {maximum}-byte limit")]
725 ContextTooLong {
726 field: &'static str,
728 maximum: usize,
730 },
731
732 #[error("baseline and candidate context ids must identify different views")]
734 IdenticalContexts,
735
736 #[error("API visibility comparison reliability must be greater than zero")]
738 ZeroReliability,
739}
740
741#[repr(transparent)]
747#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
748#[serde(transparent)]
749pub struct ComparisonId(String);
750
751impl ComparisonId {
752 pub fn new(value: impl Into<String>) -> Result<Self, ApiVocabularyError> {
754 opaque_context(value, "comparison id").map(Self)
755 }
756
757 pub fn as_str(&self) -> &str {
759 &self.0
760 }
761
762 pub fn into_string(self) -> String {
764 self.0
765 }
766}
767
768impl fmt::Debug for ComparisonId {
769 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
770 formatter
771 .debug_tuple("ComparisonId")
772 .field(&"<redacted>")
773 .finish()
774 }
775}
776
777impl AsRef<str> for ComparisonId {
778 fn as_ref(&self) -> &str {
779 self.as_str()
780 }
781}
782
783impl<'de> Deserialize<'de> for ComparisonId {
784 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
785 where
786 D: Deserializer<'de>,
787 {
788 Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
789 }
790}
791
792#[repr(transparent)]
797#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
798#[serde(transparent)]
799pub struct OpaqueContextId(String);
800
801impl OpaqueContextId {
802 pub fn new(value: impl Into<String>) -> Result<Self, ApiVocabularyError> {
804 Self::new_for_field(value, "context id")
805 }
806
807 fn new_for_field(
808 value: impl Into<String>,
809 field: &'static str,
810 ) -> Result<Self, ApiVocabularyError> {
811 opaque_context(value, field).map(Self)
812 }
813
814 pub fn as_str(&self) -> &str {
816 &self.0
817 }
818
819 pub fn into_string(self) -> String {
821 self.0
822 }
823}
824
825impl fmt::Debug for OpaqueContextId {
826 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
827 formatter
828 .debug_tuple("OpaqueContextId")
829 .field(&"<redacted>")
830 .finish()
831 }
832}
833
834impl AsRef<str> for OpaqueContextId {
835 fn as_ref(&self) -> &str {
836 self.as_str()
837 }
838}
839
840impl<'de> Deserialize<'de> for OpaqueContextId {
841 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
842 where
843 D: Deserializer<'de>,
844 {
845 Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
846 }
847}
848
849#[repr(transparent)]
854#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
855#[serde(transparent)]
856pub struct ResourceScopeId(String);
857
858impl ResourceScopeId {
859 pub fn new(value: impl Into<String>) -> Result<Self, ApiVocabularyError> {
861 opaque_context(value, "resource scope id").map(Self)
862 }
863
864 pub fn as_str(&self) -> &str {
866 &self.0
867 }
868
869 pub fn into_string(self) -> String {
871 self.0
872 }
873}
874
875impl fmt::Debug for ResourceScopeId {
876 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
877 formatter
878 .debug_tuple("ResourceScopeId")
879 .field(&"<redacted>")
880 .finish()
881 }
882}
883
884impl AsRef<str> for ResourceScopeId {
885 fn as_ref(&self) -> &str {
886 self.as_str()
887 }
888}
889
890impl<'de> Deserialize<'de> for ResourceScopeId {
891 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
892 where
893 D: Deserializer<'de>,
894 {
895 Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
896 }
897}
898
899#[derive(Debug, Clone, PartialEq, Eq)]
906pub struct ApiVisibilityObservation {
907 evidence: Evidence,
908 resource_scope: EntityId,
909 scope_relation: KnowledgeRelation,
910}
911
912impl ApiVisibilityObservation {
913 pub fn evidence(&self) -> &Evidence {
915 &self.evidence
916 }
917
918 pub fn resource_scope(&self) -> &EntityId {
920 &self.resource_scope
921 }
922
923 pub fn scope_relation(&self) -> &KnowledgeRelation {
925 &self.scope_relation
926 }
927
928 pub fn into_parts(self) -> (Evidence, KnowledgeRelation) {
930 (self.evidence, self.scope_relation)
931 }
932}
933
934#[derive(Clone, PartialEq, Eq, Serialize)]
970pub struct ApiVisibilityComparison {
971 comparison_id: ComparisonId,
972 surface: ApiSurfaceKind,
973 pair: ApiVisibilityPairKind,
974 result: ApiVisibilityResult,
975 dimension: ApiVisibilityDimension,
976 baseline_context_id: OpaqueContextId,
977 candidate_context_id: OpaqueContextId,
978 resource_scope_id: ResourceScopeId,
979 observed_at_ms: u64,
980}
981
982impl fmt::Debug for ApiVisibilityComparison {
983 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
984 formatter
985 .debug_struct("ApiVisibilityComparison")
986 .field("comparison_id", &"<redacted>")
987 .field("surface", &self.surface)
988 .field("pair", &self.pair)
989 .field("result", &self.result)
990 .field("dimension", &self.dimension)
991 .field("baseline_context_id", &"<redacted>")
992 .field("candidate_context_id", &"<redacted>")
993 .field("resource_scope_id", &"<redacted>")
994 .field("observed_at_ms", &self.observed_at_ms)
995 .finish()
996 }
997}
998
999impl ApiVisibilityComparison {
1000 #[allow(clippy::too_many_arguments)]
1002 pub fn new(
1003 comparison_id: impl Into<String>,
1004 surface: ApiSurfaceKind,
1005 pair: ApiVisibilityPairKind,
1006 result: ApiVisibilityResult,
1007 dimension: ApiVisibilityDimension,
1008 baseline_context_id: impl Into<String>,
1009 candidate_context_id: impl Into<String>,
1010 resource_scope_id: impl Into<String>,
1011 ) -> Result<Self, ApiVocabularyError> {
1012 let comparison_id = ComparisonId::new(comparison_id)?;
1013 let baseline_context_id =
1014 OpaqueContextId::new_for_field(baseline_context_id, "baseline context id")?;
1015 let candidate_context_id =
1016 OpaqueContextId::new_for_field(candidate_context_id, "candidate context id")?;
1017 let resource_scope_id = ResourceScopeId::new(resource_scope_id)?;
1018 Self::new_typed(
1019 comparison_id,
1020 surface,
1021 pair,
1022 result,
1023 dimension,
1024 baseline_context_id,
1025 candidate_context_id,
1026 resource_scope_id,
1027 )
1028 }
1029
1030 #[allow(clippy::too_many_arguments)]
1035 pub fn new_typed(
1036 comparison_id: ComparisonId,
1037 surface: ApiSurfaceKind,
1038 pair: ApiVisibilityPairKind,
1039 result: ApiVisibilityResult,
1040 dimension: ApiVisibilityDimension,
1041 baseline_context_id: OpaqueContextId,
1042 candidate_context_id: OpaqueContextId,
1043 resource_scope_id: ResourceScopeId,
1044 ) -> Result<Self, ApiVocabularyError> {
1045 if baseline_context_id == candidate_context_id {
1046 return Err(ApiVocabularyError::IdenticalContexts);
1047 }
1048 Ok(Self {
1049 comparison_id,
1050 surface,
1051 pair,
1052 result,
1053 dimension,
1054 baseline_context_id,
1055 candidate_context_id,
1056 resource_scope_id,
1057 observed_at_ms: unix_time_ms(),
1058 })
1059 }
1060
1061 pub fn comparison_id(&self) -> &str {
1063 self.comparison_id.as_str()
1064 }
1065
1066 pub const fn typed_comparison_id(&self) -> &ComparisonId {
1068 &self.comparison_id
1069 }
1070
1071 pub const fn baseline_context_id(&self) -> &OpaqueContextId {
1073 &self.baseline_context_id
1074 }
1075
1076 pub const fn candidate_context_id(&self) -> &OpaqueContextId {
1078 &self.candidate_context_id
1079 }
1080
1081 pub const fn resource_scope_id(&self) -> &ResourceScopeId {
1083 &self.resource_scope_id
1084 }
1085
1086 pub const fn surface(&self) -> ApiSurfaceKind {
1088 self.surface
1089 }
1090
1091 pub const fn pair(&self) -> ApiVisibilityPairKind {
1093 self.pair
1094 }
1095
1096 pub const fn result(&self) -> ApiVisibilityResult {
1098 self.result
1099 }
1100
1101 pub const fn dimension(&self) -> ApiVisibilityDimension {
1103 self.dimension
1104 }
1105
1106 pub const fn observed_at_ms(&self) -> u64 {
1108 self.observed_at_ms
1109 }
1110
1111 pub fn with_observed_at_ms(mut self, observed_at_ms: u64) -> Self {
1113 self.observed_at_ms = observed_at_ms;
1114 self
1115 }
1116
1117 pub fn subject(&self) -> EntityId {
1122 comparison_subject(&self.digest())
1123 }
1124
1125 pub fn resource_scope(&self) -> EntityId {
1127 EntityId::new(self.resource_scope_id.as_str().to_owned())
1128 .expect("validated opaque resource scope is a valid entity id")
1129 }
1130
1131 pub fn to_observation(
1133 &self,
1134 component: impl Into<String>,
1135 reliability: ConfidenceScore,
1136 ) -> Result<ApiVisibilityObservation, ApiVocabularyError> {
1137 let evidence = self.build_evidence(component, reliability)?;
1138 let digest = self.digest();
1139 let resource_scope = self.resource_scope();
1140 let scope_relation = KnowledgeRelation::with_id(
1141 RelationId::parse(format!("api-comparison-scope:{digest}"))?,
1142 evidence.subject().clone(),
1143 resource_scope.clone(),
1144 RelationKind::Custom("api.visibility.resource-scope".to_owned()),
1145 reliability,
1146 evidence.id().clone(),
1147 );
1148 Ok(ApiVisibilityObservation {
1149 evidence,
1150 resource_scope,
1151 scope_relation,
1152 })
1153 }
1154
1155 pub fn to_evidence(
1162 &self,
1163 component: impl Into<String>,
1164 reliability: ConfidenceScore,
1165 ) -> Result<Evidence, ApiVocabularyError> {
1166 self.build_evidence(component, reliability)
1167 }
1168
1169 fn build_evidence(
1170 &self,
1171 component: impl Into<String>,
1172 reliability: ConfidenceScore,
1173 ) -> Result<Evidence, ApiVocabularyError> {
1174 if reliability == ConfidenceScore::NONE {
1175 return Err(ApiVocabularyError::ZeroReliability);
1176 }
1177 let digest = self.digest();
1178 let subject = comparison_subject(&digest);
1179 let evidence_id = EvidenceId::parse(format!("api-comparison-evidence:{digest}"))?;
1180 let source = EvidenceSource::new(component, "paired-api-visibility")?
1181 .with_correlation_id(subject.as_str())?;
1182 Ok(Evidence::with_id_at(
1183 evidence_id,
1184 subject,
1185 EvidenceKind::Custom("api.visibility-comparison".to_owned()),
1186 ApiEvidencePredicate::visibility(self.surface, self.pair, self.result).into(),
1187 self.dimension.into(),
1188 source,
1189 reliability,
1190 self.observed_at_ms,
1191 ))
1192 }
1193
1194 fn digest(&self) -> String {
1195 let mut digest = Sha256::new();
1196 for value in [
1197 self.comparison_id.as_str(),
1198 self.surface.as_str(),
1199 self.pair.as_str(),
1200 self.result.as_str(),
1201 self.dimension.as_str(),
1202 self.baseline_context_id.as_str(),
1203 self.candidate_context_id.as_str(),
1204 self.resource_scope_id.as_str(),
1205 ] {
1206 let bytes = value.as_bytes();
1207 digest.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
1208 digest.update(bytes);
1209 }
1210 hex::encode(digest.finalize())
1211 }
1212}
1213
1214fn comparison_subject(digest: &str) -> EntityId {
1215 EntityId::new(format!("api-comparison:{digest}"))
1216 .expect("a prefixed SHA-256 digest is a valid entity id")
1217}
1218
1219impl<'de> Deserialize<'de> for ApiVisibilityComparison {
1220 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1221 where
1222 D: Deserializer<'de>,
1223 {
1224 #[derive(Deserialize)]
1225 struct WireComparison {
1226 comparison_id: String,
1227 surface: ApiSurfaceKind,
1228 pair: ApiVisibilityPairKind,
1229 result: ApiVisibilityResult,
1230 dimension: ApiVisibilityDimension,
1231 baseline_context_id: String,
1232 candidate_context_id: String,
1233 resource_scope_id: String,
1234 observed_at_ms: u64,
1235 }
1236
1237 let wire = WireComparison::deserialize(deserializer)?;
1238 Self::new(
1239 wire.comparison_id,
1240 wire.surface,
1241 wire.pair,
1242 wire.result,
1243 wire.dimension,
1244 wire.baseline_context_id,
1245 wire.candidate_context_id,
1246 wire.resource_scope_id,
1247 )
1248 .map(|comparison| comparison.with_observed_at_ms(wire.observed_at_ms))
1249 .map_err(serde::de::Error::custom)
1250 }
1251}
1252
1253fn opaque_context(
1254 value: impl Into<String>,
1255 field: &'static str,
1256) -> Result<String, ApiVocabularyError> {
1257 let value = value.into();
1258 if value.trim().is_empty() {
1259 return Err(ApiVocabularyError::EmptyContext { field });
1260 }
1261 if value.len() > MAX_OPAQUE_CONTEXT_BYTES {
1262 return Err(ApiVocabularyError::ContextTooLong {
1263 field,
1264 maximum: MAX_OPAQUE_CONTEXT_BYTES,
1265 });
1266 }
1267 Ok(value)
1268}
1269
1270fn unix_time_ms() -> u64 {
1271 SystemTime::now()
1272 .duration_since(UNIX_EPOCH)
1273 .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
1274 .unwrap_or(0)
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279 use std::collections::BTreeSet;
1280
1281 use super::*;
1282
1283 #[test]
1284 fn fixed_http_descriptors_are_unique_and_preserve_wire_shape() {
1285 let descriptors = HttpEvidencePredicate::fixed();
1286 let unique = descriptors
1287 .iter()
1288 .map(|descriptor| descriptor.dotted())
1289 .collect::<BTreeSet<_>>();
1290
1291 assert_eq!(unique.len(), descriptors.len());
1292 assert_eq!(
1293 serde_json::to_value(HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge()).unwrap(),
1294 serde_json::json!({"namespace": "http.response", "name": "status"})
1295 );
1296 for descriptor in descriptors {
1297 assert_eq!(descriptor.into_knowledge().dotted(), descriptor.dotted());
1298 }
1299 }
1300
1301 #[test]
1302 fn fixed_web_discovery_descriptors_are_unique_ordered_and_preserve_wire_shape() {
1303 let descriptors = WebDiscoveryEvidencePredicate::fixed();
1304 let dotted: Vec<_> = descriptors
1305 .iter()
1306 .map(|descriptor| descriptor.dotted())
1307 .collect();
1308 let unique = dotted.iter().copied().collect::<BTreeSet<_>>();
1309
1310 assert_eq!(descriptors.len(), 21);
1311 assert_eq!(unique.len(), descriptors.len());
1312 assert_eq!(
1313 dotted,
1314 vec![
1315 "web.discovery.document-projected",
1316 "web.discovery.document-body-incomplete",
1317 "web.discovery.document-partial-representation",
1318 "web.discovery.document-invalid-utf8",
1319 "web.discovery.document-route-limit-reached",
1320 "web.discovery.document-form-limit-reached",
1321 "web.discovery.document-control-limit-reached",
1322 "web.discovery.document-query-name-limit-reached",
1323 "web.discovery.document-url-byte-limit-reached",
1324 "web.discovery.assessment-subject-limit-reached",
1325 "web.discovery.assessment-depth-limit-reached",
1326 "web.discovery.assessment-retained-url-byte-limit-reached",
1327 "web.discovery.document-outside-origin-reference-count",
1328 "web.discovery.get-route",
1329 "web.discovery.head-route",
1330 "web.discovery.route-query-parameter-names",
1331 "web.discovery.get-form-action",
1332 "web.discovery.post-form-action",
1333 "web.discovery.dialog-form-action",
1334 "web.discovery.form-query-parameter-names",
1335 "web.discovery.form-control-names",
1336 ]
1337 );
1338 assert_eq!(
1339 serde_json::to_value(
1340 WebDiscoveryEvidencePredicate::DOCUMENT_PROJECTED.into_knowledge()
1341 )
1342 .unwrap(),
1343 serde_json::json!({"namespace": "web.discovery", "name": "document-projected"})
1344 );
1345 for descriptor in descriptors {
1346 assert_eq!(descriptor.into_knowledge().dotted(), descriptor.dotted());
1347 }
1348 }
1349
1350 #[test]
1351 fn dynamic_header_family_remains_open() {
1352 let predicate = HttpEvidencePredicate::response_header("x-private-signal").unwrap();
1353
1354 assert_eq!(predicate.namespace(), "http.header");
1355 assert_eq!(predicate.name(), "x-private-signal");
1356 }
1357
1358 fn comparison(candidate: &str) -> ApiVisibilityComparison {
1359 ApiVisibilityComparison::new(
1360 "comparison-7",
1361 ApiSurfaceKind::GraphQl,
1362 ApiVisibilityPairKind::AuthorizationContext,
1363 ApiVisibilityResult::Different,
1364 ApiVisibilityDimension::Fields,
1365 "anonymous",
1366 candidate,
1367 "resource-42",
1368 )
1369 .unwrap()
1370 }
1371
1372 #[test]
1373 fn comparison_emits_one_atomic_pseudonymous_observation() {
1374 let comparison = comparison("member");
1375 let evidence = comparison
1376 .to_evidence("api.visibility", ConfidenceScore::from_percent(95).unwrap())
1377 .unwrap();
1378
1379 assert!(evidence.subject().as_str().starts_with("api-comparison:"));
1380 assert_eq!(
1381 evidence.predicate(),
1382 &ApiEvidencePredicate::GRAPHQL_AUTHORIZATION_CONTEXT_DIFFERENCE.into_knowledge()
1383 );
1384 assert_eq!(evidence.value(), &EvidenceValue::Text("fields".to_owned()));
1385 assert_eq!(
1386 evidence.source().correlation_id(),
1387 Some(evidence.subject().as_str())
1388 );
1389 assert_eq!(evidence.source().method(), "paired-api-visibility");
1390 let encoded = serde_json::to_string(&evidence).unwrap();
1391 for secret_adjacent_value in ["anonymous", "member", "resource-42"] {
1392 assert!(!encoded.contains(secret_adjacent_value));
1393 }
1394 }
1395
1396 #[test]
1397 fn comparison_bundle_links_pseudonymous_subject_to_resource_scope() {
1398 let observation = comparison("member")
1399 .with_observed_at_ms(1_000)
1400 .to_observation("api.visibility", ConfidenceScore::MAX)
1401 .unwrap();
1402
1403 assert_eq!(
1404 observation.scope_relation().from(),
1405 observation.evidence().subject()
1406 );
1407 assert_eq!(
1408 observation.scope_relation().to(),
1409 observation.resource_scope()
1410 );
1411 assert_eq!(observation.resource_scope().as_str(), "resource-42");
1412 assert_eq!(
1413 observation.scope_relation().evidence_ids(),
1414 &std::collections::BTreeSet::from([observation.evidence().id().clone()])
1415 );
1416 assert!(matches!(
1417 observation.scope_relation().kind(),
1418 RelationKind::Custom(kind) if kind == "api.visibility.resource-scope"
1419 ));
1420 assert!(observation
1421 .scope_relation()
1422 .id()
1423 .as_str()
1424 .starts_with("api-comparison-scope:"));
1425 }
1426
1427 #[test]
1428 fn comparison_identity_is_stable_and_context_scoped() {
1429 let paired = comparison("member").with_observed_at_ms(1_000);
1430 let first = paired
1431 .to_evidence("api.visibility", ConfidenceScore::MAX)
1432 .unwrap();
1433 let replay = paired
1434 .to_evidence("api.visibility", ConfidenceScore::MAX)
1435 .unwrap();
1436 let later = comparison("member")
1437 .with_observed_at_ms(2_000)
1438 .to_evidence("api.visibility", ConfidenceScore::MAX)
1439 .unwrap();
1440
1441 assert_eq!(
1442 comparison("member").subject(),
1443 comparison("member").subject()
1444 );
1445 assert_eq!(first, replay);
1446 assert_eq!(first.id(), later.id());
1447 assert_ne!(first, later);
1448 assert_ne!(
1449 comparison("member").subject(),
1450 comparison("admin").subject()
1451 );
1452 }
1453
1454 #[test]
1455 fn typed_comparison_identifiers_preserve_legacy_wire_and_identity() {
1456 let typed = ApiVisibilityComparison::new_typed(
1457 ComparisonId::new("comparison-7").unwrap(),
1458 ApiSurfaceKind::GraphQl,
1459 ApiVisibilityPairKind::AuthorizationContext,
1460 ApiVisibilityResult::Different,
1461 ApiVisibilityDimension::Fields,
1462 OpaqueContextId::new("anonymous").unwrap(),
1463 OpaqueContextId::new("member").unwrap(),
1464 ResourceScopeId::new("resource-42").unwrap(),
1465 )
1466 .unwrap()
1467 .with_observed_at_ms(1_234);
1468 let legacy = comparison("member").with_observed_at_ms(1_234);
1469
1470 assert_eq!(typed, legacy);
1471 assert_eq!(typed.comparison_id(), "comparison-7");
1472 assert_eq!(typed.typed_comparison_id().as_str(), "comparison-7");
1473 assert_eq!(typed.baseline_context_id().as_str(), "anonymous");
1474 assert_eq!(typed.candidate_context_id().as_str(), "member");
1475 assert_eq!(typed.resource_scope_id().as_str(), "resource-42");
1476 assert_eq!(
1477 typed.subject().as_str(),
1478 "api-comparison:2c9736b8ec3f7a945eba1822c36e9bd033ff45b24421ec387859e977b6d434b2"
1479 );
1480 assert_eq!(
1481 serde_json::to_value(&typed).unwrap(),
1482 serde_json::json!({
1483 "comparison_id": "comparison-7",
1484 "surface": "graphql",
1485 "pair": "authorization-context",
1486 "result": "different",
1487 "dimension": "fields",
1488 "baseline_context_id": "anonymous",
1489 "candidate_context_id": "member",
1490 "resource_scope_id": "resource-42",
1491 "observed_at_ms": 1_234,
1492 })
1493 );
1494 }
1495
1496 #[test]
1497 fn opaque_identifier_round_trips_validate_and_redact_debug_output() {
1498 let comparison_id = ComparisonId::new("sensitive-comparison-handle").unwrap();
1499 let context_id = OpaqueContextId::new("sensitive-context-handle").unwrap();
1500 let resource_id = ResourceScopeId::new("sensitive-resource-handle").unwrap();
1501
1502 assert_eq!(
1503 serde_json::from_value::<ComparisonId>(serde_json::to_value(&comparison_id).unwrap())
1504 .unwrap(),
1505 comparison_id
1506 );
1507 assert_eq!(
1508 serde_json::from_value::<OpaqueContextId>(serde_json::to_value(&context_id).unwrap())
1509 .unwrap(),
1510 context_id
1511 );
1512 assert_eq!(
1513 serde_json::from_value::<ResourceScopeId>(serde_json::to_value(&resource_id).unwrap())
1514 .unwrap(),
1515 resource_id
1516 );
1517 assert!(serde_json::from_str::<OpaqueContextId>("\" \"").is_err());
1518
1519 let debug = format!("{comparison_id:?} {context_id:?} {resource_id:?}");
1520 for secret in [
1521 "sensitive-comparison-handle",
1522 "sensitive-context-handle",
1523 "sensitive-resource-handle",
1524 ] {
1525 assert!(!debug.contains(secret));
1526 }
1527 assert_eq!(debug.matches("<redacted>").count(), 3);
1528 assert_eq!(
1529 ComparisonId::new("consumed-comparison")
1530 .unwrap()
1531 .into_string(),
1532 "consumed-comparison"
1533 );
1534 }
1535
1536 #[test]
1537 fn comparison_debug_redacts_every_opaque_identifier() {
1538 let comparison = ApiVisibilityComparison::new(
1539 "sensitive-comparison",
1540 ApiSurfaceKind::JsonHttp,
1541 ApiVisibilityPairKind::AuthorizationContext,
1542 ApiVisibilityResult::Different,
1543 ApiVisibilityDimension::Fields,
1544 "sensitive-baseline",
1545 "sensitive-candidate",
1546 "sensitive-resource",
1547 )
1548 .unwrap();
1549
1550 let debug = format!("{comparison:?}");
1551 for secret in [
1552 "sensitive-comparison",
1553 "sensitive-baseline",
1554 "sensitive-candidate",
1555 "sensitive-resource",
1556 ] {
1557 assert!(!debug.contains(secret));
1558 }
1559 assert_eq!(debug.matches("<redacted>").count(), 4);
1560 assert!(debug.contains("JsonHttp"));
1561 }
1562
1563 #[test]
1564 fn comparison_round_trip_revalidates_bounded_contexts() {
1565 let paired = comparison("member");
1566 let encoded = serde_json::to_value(&paired).unwrap();
1567
1568 assert_eq!(encoded["surface"], "graphql");
1569
1570 assert_eq!(
1571 serde_json::from_value::<ApiVisibilityComparison>(encoded).unwrap(),
1572 paired
1573 );
1574 assert!(ApiVisibilityComparison::new(
1575 " ",
1576 ApiSurfaceKind::JsonHttp,
1577 ApiVisibilityPairKind::UiApi,
1578 ApiVisibilityResult::Equivalent,
1579 ApiVisibilityDimension::Status,
1580 "ui",
1581 "api",
1582 "scope",
1583 )
1584 .is_err());
1585 assert!(matches!(
1586 ApiVisibilityComparison::new(
1587 "comparison",
1588 ApiSurfaceKind::JsonHttp,
1589 ApiVisibilityPairKind::AuthorizationContext,
1590 ApiVisibilityResult::Different,
1591 ApiVisibilityDimension::Fields,
1592 "same-view",
1593 "same-view",
1594 "scope",
1595 ),
1596 Err(ApiVocabularyError::IdenticalContexts)
1597 ));
1598 assert!(matches!(
1599 comparison("member").to_evidence("api.visibility", ConfidenceScore::NONE),
1600 Err(ApiVocabularyError::ZeroReliability)
1601 ));
1602 assert!(ApiVisibilityComparison::new(
1603 "comparison",
1604 ApiSurfaceKind::JsonHttp,
1605 ApiVisibilityPairKind::UiApi,
1606 ApiVisibilityResult::Equivalent,
1607 ApiVisibilityDimension::Status,
1608 "a".repeat(MAX_OPAQUE_CONTEXT_BYTES + 1),
1609 "api",
1610 "scope",
1611 )
1612 .is_err());
1613 }
1614}