Skip to main content

termivar_scanner/
api_evidence.rs

1//! Pure, bounded evidence preparation for paired API visibility comparisons.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** always/default.
6//! - **Execution:** Surface B support (paired API visibility workflow).
7//! - **Default `termivar scan`:** no.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! This module deliberately performs no network I/O, knowledge-base writes,
13//! rule evaluation, or planning. A host captures two already-authorized JSON
14//! views, then compares one explicit visibility dimension. Raw JSON is borrowed
15//! only while canonical signatures are calculated and is never retained.
16
17use 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
42/// Hard ceiling for the maximum JSON nesting depth accepted by a comparator.
43pub const HARD_MAX_API_VISIBILITY_DEPTH: u16 = 128;
44/// Hard ceiling for the maximum JSON values accepted by one captured view.
45pub const HARD_MAX_API_VISIBILITY_NODES: u32 = 1_000_000;
46/// Hard ceiling for the total object members accepted by one captured view.
47pub const HARD_MAX_API_VISIBILITY_FIELDS: u32 = 250_000;
48/// Hard ceiling for either canonical signature stream.
49pub const HARD_MAX_API_VISIBILITY_CANONICAL_BYTES: u64 = 64 * 1024 * 1024;
50
51/// Default maximum JSON nesting depth.
52pub const DEFAULT_API_VISIBILITY_DEPTH: u16 = 64;
53/// Default maximum number of JSON values in one view.
54pub const DEFAULT_API_VISIBILITY_NODES: u32 = 100_000;
55/// Default maximum number of object members in one view.
56pub const DEFAULT_API_VISIBILITY_FIELDS: u32 = 50_000;
57/// Default maximum number of bytes in either canonical signature stream.
58pub const DEFAULT_API_VISIBILITY_CANONICAL_BYTES: u64 = 8 * 1024 * 1024;
59
60/// Fail-closed resource limits for canonicalizing one API visibility view.
61///
62/// Root JSON values have depth one. `max_nodes` counts every array, object, and
63/// scalar value, while `max_fields` counts object members across the complete
64/// tree. `max_canonical_bytes` is applied independently to the full-resource
65/// stream and the fields-only schema stream.
66///
67/// Limits are serialized so a host can persist the policy used for a replay.
68/// Deserialization revalidates every value, rejects unknown fields, and never
69/// permits a persisted policy to exceed the compiled hard ceilings.
70#[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    /// Creates a validated canonicalization envelope.
80    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    /// Returns the inclusive JSON nesting-depth limit.
115    pub const fn max_depth(self) -> u16 {
116        self.max_depth
117    }
118
119    /// Returns the inclusive JSON value-count limit.
120    pub const fn max_nodes(self) -> u32 {
121        self.max_nodes
122    }
123
124    /// Returns the inclusive object-member count limit.
125    pub const fn max_fields(self) -> u32 {
126        self.max_fields
127    }
128
129    /// Returns the inclusive byte limit for each canonical signature stream.
130    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/// A bounded, raw-value-free API response view ready for paired comparison.
172///
173/// The view retains opaque context and resource handles, the API surface, the
174/// exact HTTP status, and two SHA-256 signatures. It does **not** retain the
175/// supplied [`serde_json::Value`] or any canonical byte buffer. This type is
176/// intentionally not serializable: persist the resulting
177/// [`ApiVisibilityComparison`] and the [`ApiVisibilityLimits`] policy instead.
178///
179/// The signatures are deterministic pseudonymous fingerprints, not MACs,
180/// signatures, attestations, or substitutes for an authorized evidence-write
181/// boundary. Context and scope handles are serialized by the resulting core
182/// comparison, so callers must use bounded, non-secret opaque identifiers.
183#[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/// Raw-value-free equality across the three exact replay dimensions.
195///
196/// This crate-private result lets native replay capabilities reuse the
197/// canonical API signatures without inventing a semantically inaccurate
198/// public [`ApiVisibilityPairKind`] or exposing either signature.
199#[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    /// Returns the non-secret opaque authorization or presentation context.
243    pub fn context_id(&self) -> &str {
244        &self.context_id
245    }
246
247    /// Returns the non-secret opaque logical-resource scope.
248    pub fn resource_scope_id(&self) -> &str {
249        &self.resource_scope_id
250    }
251
252    /// Returns the declared API surface.
253    pub const fn surface(&self) -> ApiSurfaceKind {
254        self.surface
255    }
256
257    /// Returns the exact HTTP response status.
258    pub const fn status(&self) -> u16 {
259        self.status
260    }
261
262    /// Returns the policy used to create this view.
263    pub const fn limits(&self) -> ApiVisibilityLimits {
264        self.limits
265    }
266}
267
268/// Pure canonicalizer and comparator for two authorized API response views.
269///
270/// Object keys are compared in UTF-8 byte order, so input map insertion order
271/// never changes a signature. Array position and duplicate elements remain
272/// meaningful. `Fields` compares only key/schema structure, `Resources`
273/// compares the complete canonical JSON value, and `Status` compares the exact
274/// validated HTTP status.
275///
276/// JSON-number text follows the pinned `serde_json` implementation. Signatures
277/// are deterministic within this comparator version, but are not a permanent
278/// cross-version wire-hash promise; persisted replay data should record the
279/// comparator version and dependency set.
280///
281/// Comparison itself reads no clock. The caller supplies both a stable
282/// observation identity and `observed_at_ms`; replaying the same inputs with
283/// those same values produces the same [`ApiVisibilityComparison`]. A caller
284/// must allocate a new comparison identity for a genuinely new observation.
285///
286/// # Examples
287///
288/// ```rust
289/// use serde_json::json;
290/// use termivar_core::{
291///     ApiSurfaceKind, ApiVisibilityDimension, ApiVisibilityPairKind,
292///     ApiVisibilityResult,
293/// };
294/// use termivar_scanner::ApiVisibilityComparator;
295///
296/// let comparator = ApiVisibilityComparator::default();
297/// let baseline = comparator.capture_view(
298///     "anonymous-view",
299///     "resource:account-42",
300///     ApiSurfaceKind::JsonHttp,
301///     200,
302///     &json!({"id": 42}),
303/// )?;
304/// let candidate = comparator.capture_view(
305///     "member-view",
306///     "resource:account-42",
307///     ApiSurfaceKind::JsonHttp,
308///     200,
309///     &json!({"id": 42, "email": "redacted@example.test"}),
310/// )?;
311/// let comparison = comparator.compare(
312///     "comparison-17",
313///     ApiVisibilityPairKind::AuthorizationContext,
314///     ApiVisibilityDimension::Fields,
315///     &baseline,
316///     &candidate,
317///     1_800_000_000_000,
318/// )?;
319///
320/// assert_eq!(comparison.result(), ApiVisibilityResult::Different);
321/// # Ok::<(), Box<dyn std::error::Error>>(())
322/// ```
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub struct ApiVisibilityComparator {
325    limits: ApiVisibilityLimits,
326}
327
328impl ApiVisibilityComparator {
329    /// Creates a pure comparator under an already validated resource envelope.
330    pub const fn new(limits: ApiVisibilityLimits) -> Self {
331        Self { limits }
332    }
333
334    /// Returns the immutable canonicalization envelope.
335    pub const fn limits(&self) -> ApiVisibilityLimits {
336        self.limits
337    }
338
339    /// Captures bounded signatures from one borrowed JSON snapshot.
340    ///
341    /// The snapshot is traversed once and discarded when this call returns. No
342    /// raw scalar, key, body, token, or canonical byte stream is retained in the
343    /// returned view, and deterministic signatures are redacted from its
344    /// [`std::fmt::Debug`] representation.
345    ///
346    /// The supplied [`serde_json::Value`] has already been read and parsed, so
347    /// these traversal limits cannot bound transport reads or parser allocation.
348    /// The host must enforce its response-byte budget before parsing untrusted
349    /// JSON and must keep raw response values out of logs.
350    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    /// Compares one explicit dimension and emits only the typed core contract.
377    ///
378    /// Views must use this comparator's limits, the same resource scope and API
379    /// surface, and distinct context handles. `comparison_id` is a caller-owned
380    /// immutable observation identity. It and both handles must be non-secret
381    /// because the core comparison's replay form serializes them.
382    #[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    /// Compares two captures of one exact resource without assigning them an
433    /// authorization- or UI-specific public pair meaning.
434    ///
435    /// The validation is intentionally identical to [`Self::compare`]: both
436    /// views must use this comparator's bounds and the same resource/surface,
437    /// while their scanner-owned replay contexts must remain distinct.
438    #[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/// Validation and canonicalization failures for API visibility evidence.
472#[derive(Debug, Clone, PartialEq, Eq, Error)]
473#[non_exhaustive]
474pub enum ApiVisibilityEvidenceError {
475    /// A configurable resource limit was zero.
476    #[error("API visibility limit {dimension} must be greater than zero")]
477    ZeroLimit { dimension: &'static str },
478
479    /// A configurable resource limit exceeded its compiled hard ceiling.
480    #[error("API visibility limit {dimension} value {actual} exceeds hard ceiling {maximum}")]
481    HardLimitExceeded {
482        /// Rejected limit name.
483        dimension: &'static str,
484        /// Rejected value.
485        actual: u64,
486        /// Inclusive compiled ceiling.
487        maximum: u64,
488    },
489
490    /// An opaque context or scope handle was empty.
491    #[error("API visibility {field} must not be empty")]
492    EmptyHandle { field: &'static str },
493
494    /// An opaque context or scope handle exceeded the bounded core contract.
495    #[error("API visibility {field} exceeds the {maximum}-byte limit")]
496    HandleTooLong {
497        /// Rejected handle role.
498        field: &'static str,
499        /// Inclusive byte limit.
500        maximum: usize,
501    },
502
503    /// A response status was outside the standard HTTP range.
504    #[error("API visibility HTTP status {status} is outside 100..=599")]
505    InvalidHttpStatus { status: u16 },
506
507    /// JSON nesting exceeded the selected policy.
508    #[error("API visibility JSON depth {observed} exceeds limit {limit}")]
509    DepthLimitExceeded { limit: u16, observed: u64 },
510
511    /// JSON value count exceeded the selected policy.
512    #[error("API visibility JSON node count {observed} exceeds limit {limit}")]
513    NodeLimitExceeded { limit: u32, observed: u64 },
514
515    /// Object-member count exceeded the selected policy.
516    #[error("API visibility JSON field count {observed} exceeds limit {limit}")]
517    FieldLimitExceeded { limit: u32, observed: u64 },
518
519    /// A canonical signature stream exceeded the selected byte policy.
520    #[error(
521        "API visibility {signature} canonical stream reached {observed} bytes, above limit {limit}"
522    )]
523    CanonicalBytesLimitExceeded {
524        /// Stable stream name (`resources` or `fields`).
525        signature: &'static str,
526        /// Inclusive configured limit.
527        limit: u64,
528        /// Bytes required by the attempted write.
529        observed: u64,
530    },
531
532    /// Views captured under another policy cannot be mixed silently.
533    #[error("API visibility views were captured under different limits")]
534    LimitsMismatch,
535
536    /// A pair must compare two distinct authorization or presentation contexts.
537    #[error("API visibility comparison contexts must be different")]
538    IdenticalContexts,
539
540    /// Both views must describe the same host-asserted logical resource.
541    #[error("API visibility comparison resource scopes do not match")]
542    ResourceScopeMismatch,
543
544    /// Both views must describe the same declared API surface.
545    #[error("API visibility comparison surfaces do not match")]
546    SurfaceMismatch,
547
548    /// A future core dimension is not yet supported by this comparator version.
549    #[error("API visibility comparison dimension is unsupported by this comparator")]
550    UnsupportedDimension,
551
552    /// The resulting core comparison contract rejected an identifier or value.
553    #[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;