Skip to main content

termivar_scanner/web_runtime/
scan_profile.rs

1//! Strict, versioned product profiles for the deterministic web scanner.
2//!
3//! This contract deliberately exposes only reviewed product choices. It has
4//! no URL, credential, header, raw transport, concurrency, or arbitrary
5//! option fields. A profile therefore cannot expand exact-origin authority or
6//! bypass the host-owned request broker and [`WebAssessmentLimits`].
7
8use std::{fmt, str::FromStr, time::Duration};
9
10use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
11use thiserror::Error;
12
13use super::web_assessment::{WebAssessmentLimits, WebAssessmentLimitsError};
14use crate::DEFAULT_MAX_ACTIVE_VERIFICATIONS;
15
16/// Exact schema identifier for the first deterministic scan-profile contract.
17pub const SCAN_PROFILE_V1_SCHEMA: &str = "venom.scan-profile/v1";
18/// Exact built-in identifier for conservative single-resource behavior.
19pub const BASELINE_SCAN_PROFILE_ID: &str = "baseline";
20/// Exact built-in identifier for bounded exact-origin review.
21pub const WEB_REVIEW_SCAN_PROFILE_ID: &str = "web-review";
22/// Request ceiling used by the unchanged default single-resource CLI path.
23pub const BASELINE_SCAN_PROFILE_MAX_TOTAL_REQUESTS: u32 = 16;
24/// Wall-clock ceiling used by the unchanged default single-resource CLI path.
25pub const BASELINE_SCAN_PROFILE_MAX_WALL_TIME_MS: u64 = 60_000;
26/// Cumulative response-byte ceiling used by the unchanged default CLI path.
27pub const BASELINE_SCAN_PROFILE_MAX_TOTAL_RESPONSE_BYTES: u64 = 1024 * 1024;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30enum ScanProfileSchemaV1 {
31    #[serde(rename = "venom.scan-profile/v1")]
32    V1,
33}
34
35impl<'de> Deserialize<'de> for ScanProfileSchemaV1 {
36    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
37    where
38        D: Deserializer<'de>,
39    {
40        let value = String::deserialize(deserializer)?;
41        match value.as_str() {
42            SCAN_PROFILE_V1_SCHEMA => Ok(Self::V1),
43            _ => Err(D::Error::custom("unknown scan-profile schema version")),
44        }
45    }
46}
47
48/// One of the two product profiles implemented by the deterministic runtime.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[non_exhaustive]
51pub enum BuiltInScanProfile {
52    /// Conservative single-resource behavior used by the default CLI path.
53    #[serde(rename = "baseline")]
54    Baseline,
55    /// Bounded exact-origin discovery and evidence review.
56    #[serde(rename = "web-review")]
57    WebReview,
58}
59
60impl<'de> Deserialize<'de> for BuiltInScanProfile {
61    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62    where
63        D: Deserializer<'de>,
64    {
65        let value = String::deserialize(deserializer)?;
66        Self::parse_id(&value).map_err(D::Error::custom)
67    }
68}
69
70impl BuiltInScanProfile {
71    /// Returns the stable wire identifier.
72    pub const fn id(self) -> &'static str {
73        match self {
74            Self::Baseline => BASELINE_SCAN_PROFILE_ID,
75            Self::WebReview => WEB_REVIEW_SCAN_PROFILE_ID,
76        }
77    }
78
79    const fn required_scope(self) -> ScanProfileScope {
80        match self {
81            Self::Baseline => ScanProfileScope::SingleResource,
82            Self::WebReview => ScanProfileScope::ExactOrigin,
83        }
84    }
85
86    const fn required_capabilities(self) -> ScanProfileCapabilitiesV1 {
87        match self {
88            Self::Baseline => ScanProfileCapabilitiesV1::BASELINE,
89            Self::WebReview => ScanProfileCapabilitiesV1::WEB_REVIEW,
90        }
91    }
92}
93
94impl fmt::Display for BuiltInScanProfile {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        formatter.write_str(self.id())
97    }
98}
99
100impl FromStr for BuiltInScanProfile {
101    type Err = BuiltInScanProfileParseError;
102
103    fn from_str(value: &str) -> Result<Self, Self::Err> {
104        Self::parse_id(value)
105    }
106}
107
108/// An input was not one of the exact built-in profile identifiers.
109///
110/// The rejected value is intentionally not retained or echoed.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
112#[error("unknown built-in scan profile identifier")]
113pub struct BuiltInScanProfileParseError;
114
115/// Network scope selected by a versioned scan profile.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
117#[non_exhaustive]
118pub enum ScanProfileScope {
119    /// Preserve the conservative single-resource decision runtime.
120    #[serde(rename = "single-resource")]
121    SingleResource,
122    /// Assess a bounded set of subjects under one exact-origin authority.
123    #[serde(rename = "exact-origin")]
124    ExactOrigin,
125}
126
127impl<'de> Deserialize<'de> for ScanProfileScope {
128    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129    where
130        D: Deserializer<'de>,
131    {
132        let value = String::deserialize(deserializer)?;
133        match value.as_str() {
134            "single-resource" => Ok(Self::SingleResource),
135            "exact-origin" => Ok(Self::ExactOrigin),
136            _ => Err(D::Error::custom("unknown scan-profile scope")),
137        }
138    }
139}
140
141impl ScanProfileScope {
142    /// Returns the stable wire identifier.
143    pub const fn id(self) -> &'static str {
144        match self {
145            Self::SingleResource => "single-resource",
146            Self::ExactOrigin => "exact-origin",
147        }
148    }
149}
150
151impl fmt::Display for ScanProfileScope {
152    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153        formatter.write_str(self.id())
154    }
155}
156
157/// Closed capability manifest for a v1 profile.
158///
159/// These flags describe executable behavior, not roadmap intent. They are
160/// validated as an exact built-in matrix and cannot be independently toggled.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
162#[serde(deny_unknown_fields)]
163pub struct ScanProfileCapabilitiesV1 {
164    standard_web_decision: bool,
165    origin_discovery: bool,
166    semantic_extraction: bool,
167    defense_observation: bool,
168    defense_shadow_planning: bool,
169    passive_security_review: bool,
170    low_risk_differential_review: bool,
171}
172
173#[derive(Deserialize)]
174#[serde(deny_unknown_fields)]
175struct WireScanProfileCapabilitiesV1 {
176    standard_web_decision: bool,
177    origin_discovery: bool,
178    semantic_extraction: bool,
179    defense_observation: bool,
180    defense_shadow_planning: bool,
181    passive_security_review: bool,
182    low_risk_differential_review: bool,
183}
184
185impl WireScanProfileCapabilitiesV1 {
186    const fn into_capabilities(self) -> ScanProfileCapabilitiesV1 {
187        ScanProfileCapabilitiesV1 {
188            standard_web_decision: self.standard_web_decision,
189            origin_discovery: self.origin_discovery,
190            semantic_extraction: self.semantic_extraction,
191            defense_observation: self.defense_observation,
192            defense_shadow_planning: self.defense_shadow_planning,
193            passive_security_review: self.passive_security_review,
194            low_risk_differential_review: self.low_risk_differential_review,
195        }
196    }
197}
198
199impl ScanProfileCapabilitiesV1 {
200    const BASELINE: Self = Self {
201        standard_web_decision: true,
202        origin_discovery: false,
203        semantic_extraction: false,
204        defense_observation: false,
205        defense_shadow_planning: false,
206        passive_security_review: false,
207        low_risk_differential_review: false,
208    };
209
210    // Expose only capabilities that execute on this branch. Both passive and
211    // closed low-risk differential review are composed by the origin
212    // assessment under its one shared authority.
213    const WEB_REVIEW: Self = Self {
214        standard_web_decision: true,
215        origin_discovery: true,
216        semantic_extraction: true,
217        defense_observation: true,
218        defense_shadow_planning: true,
219        passive_security_review: true,
220        low_risk_differential_review: true,
221    };
222
223    /// Whether the standard single-subject decision primitive executes.
224    pub const fn standard_web_decision(self) -> bool {
225        self.standard_web_decision
226    }
227
228    /// Whether bounded exact-origin discovery executes.
229    pub const fn origin_discovery(self) -> bool {
230        self.origin_discovery
231    }
232
233    /// Whether committed assessment evidence is semantically projected.
234    pub const fn semantic_extraction(self) -> bool {
235        self.semantic_extraction
236    }
237
238    /// Whether response evidence is projected into defense state.
239    pub const fn defense_observation(self) -> bool {
240        self.defense_observation
241    }
242
243    /// Whether defense-aware planning is recorded without enforcement.
244    pub const fn defense_shadow_planning(self) -> bool {
245        self.defense_shadow_planning
246    }
247
248    /// Whether bounded response metadata is projected into passive assessment
249    /// observations. This remains distinct from matched differential review.
250    pub const fn passive_security_review(self) -> bool {
251        self.passive_security_review
252    }
253
254    /// Whether the native low-risk differential review set executes.
255    pub const fn low_risk_differential_review(self) -> bool {
256        self.low_risk_differential_review
257    }
258}
259
260/// Complete serialized resource envelope for a v1 assessment profile.
261///
262/// Construction and deserialization route every value through the checked
263/// [`WebAssessmentLimits`] setters. The fixed concurrency of that authority is
264/// intentionally not configurable here.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
266#[serde(deny_unknown_fields)]
267pub struct ScanProfileLimitsV1 {
268    max_subjects: usize,
269    max_discovery_depth: u16,
270    max_references_per_document: usize,
271    max_canonical_url_bytes: usize,
272    max_retained_url_bytes: usize,
273    max_forms: usize,
274    max_controls_per_form: usize,
275    max_query_parameter_names: usize,
276    max_total_requests: u32,
277    max_response_body_bytes: usize,
278    max_total_response_bytes: u64,
279    max_wall_time_ms: u64,
280    max_active_verifications: u16,
281    #[serde(skip)]
282    checked: WebAssessmentLimits,
283}
284
285impl ScanProfileLimitsV1 {
286    fn from_checked(checked: WebAssessmentLimits) -> Result<Self, ScanProfileV1Error> {
287        if Duration::from_millis(duration_millis(checked.max_wall_time()))
288            != checked.max_wall_time()
289        {
290            return Err(ScanProfileV1Error::SubMillisecondWallTime);
291        }
292        Ok(Self {
293            max_subjects: checked.max_subjects(),
294            max_discovery_depth: checked.max_discovery_depth(),
295            max_references_per_document: checked.max_references_per_document(),
296            max_canonical_url_bytes: checked.max_canonical_url_bytes(),
297            max_retained_url_bytes: checked.max_retained_url_bytes(),
298            max_forms: checked.max_forms(),
299            max_controls_per_form: checked.max_controls_per_form(),
300            max_query_parameter_names: checked.max_query_parameter_names(),
301            max_total_requests: checked.max_total_requests(),
302            max_response_body_bytes: checked.max_response_body_bytes(),
303            max_total_response_bytes: checked.max_total_response_bytes(),
304            max_wall_time_ms: duration_millis(checked.max_wall_time()),
305            max_active_verifications: checked.max_active_verifications(),
306            checked,
307        })
308    }
309
310    /// Creates a wire envelope from an already checked assessment envelope.
311    ///
312    /// The assessment limit must use whole-millisecond wall-time precision so
313    /// serialization cannot silently change the effective runtime authority.
314    pub fn from_web_assessment_limits(
315        limits: WebAssessmentLimits,
316    ) -> Result<Self, ScanProfileV1Error> {
317        Self::from_checked(limits)
318    }
319
320    /// Returns the checked assessment envelope.
321    pub const fn web_assessment_limits(&self) -> WebAssessmentLimits {
322        self.checked
323    }
324
325    /// Returns the subject ceiling, including the authorized root.
326    pub const fn max_subjects(&self) -> usize {
327        self.max_subjects
328    }
329
330    /// Returns the discovery depth after the root.
331    pub const fn max_discovery_depth(&self) -> u16 {
332        self.max_discovery_depth
333    }
334
335    /// Returns the reference ceiling per document.
336    pub const fn max_references_per_document(&self) -> usize {
337        self.max_references_per_document
338    }
339
340    /// Returns the canonical URL byte ceiling.
341    pub const fn max_canonical_url_bytes(&self) -> usize {
342        self.max_canonical_url_bytes
343    }
344
345    /// Returns the retained canonical-URL byte ceiling.
346    pub const fn max_retained_url_bytes(&self) -> usize {
347        self.max_retained_url_bytes
348    }
349
350    /// Returns the assessment-wide form ceiling.
351    pub const fn max_forms(&self) -> usize {
352        self.max_forms
353    }
354
355    /// Returns the control-name ceiling per form.
356    pub const fn max_controls_per_form(&self) -> usize {
357        self.max_controls_per_form
358    }
359
360    /// Returns the query-name ceiling per reference.
361    pub const fn max_query_parameter_names(&self) -> usize {
362        self.max_query_parameter_names
363    }
364
365    /// Returns the authority-wide request ceiling.
366    pub const fn max_total_requests(&self) -> u32 {
367        self.max_total_requests
368    }
369
370    /// Returns the per-response body ceiling.
371    pub const fn max_response_body_bytes(&self) -> usize {
372        self.max_response_body_bytes
373    }
374
375    /// Returns the authority-wide response-byte ceiling.
376    pub const fn max_total_response_bytes(&self) -> u64 {
377        self.max_total_response_bytes
378    }
379
380    /// Returns the authority-wide wall-clock ceiling in milliseconds.
381    pub const fn max_wall_time_ms(&self) -> u64 {
382        self.max_wall_time_ms
383    }
384
385    /// Returns the active-verification request ceiling.
386    pub const fn max_active_verifications(&self) -> u16 {
387        self.max_active_verifications
388    }
389
390    /// Returns the non-configurable assessment concurrency.
391    pub const fn concurrency(&self) -> usize {
392        self.checked.concurrency()
393    }
394}
395
396#[derive(Deserialize)]
397#[serde(deny_unknown_fields)]
398struct WireScanProfileLimitsV1 {
399    max_subjects: usize,
400    max_discovery_depth: u16,
401    max_references_per_document: usize,
402    max_canonical_url_bytes: usize,
403    max_retained_url_bytes: usize,
404    max_forms: usize,
405    max_controls_per_form: usize,
406    max_query_parameter_names: usize,
407    max_total_requests: u32,
408    max_response_body_bytes: usize,
409    max_total_response_bytes: u64,
410    max_wall_time_ms: u64,
411    max_active_verifications: u16,
412}
413
414impl WireScanProfileLimitsV1 {
415    fn into_checked(self) -> Result<WebAssessmentLimits, WebAssessmentLimitsError> {
416        WebAssessmentLimits::default()
417            .with_max_subjects(self.max_subjects)?
418            .with_max_discovery_depth(self.max_discovery_depth)?
419            .with_max_references_per_document(self.max_references_per_document)?
420            .with_max_canonical_url_bytes(self.max_canonical_url_bytes)?
421            .with_max_retained_url_bytes(self.max_retained_url_bytes)?
422            .with_max_forms(self.max_forms)?
423            .with_max_controls_per_form(self.max_controls_per_form)?
424            .with_max_query_parameter_names(self.max_query_parameter_names)?
425            .with_max_total_requests(self.max_total_requests)?
426            .with_max_response_body_bytes(self.max_response_body_bytes)?
427            .with_max_total_response_bytes(self.max_total_response_bytes)?
428            .with_max_wall_time(Duration::from_millis(self.max_wall_time_ms))?
429            .with_max_active_verifications(self.max_active_verifications)
430    }
431}
432
433impl<'de> Deserialize<'de> for ScanProfileLimitsV1 {
434    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
435    where
436        D: Deserializer<'de>,
437    {
438        let wire = WireScanProfileLimitsV1::deserialize(deserializer)?;
439        let checked = wire.into_checked().map_err(D::Error::custom)?;
440        Self::from_checked(checked).map_err(D::Error::custom)
441    }
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(deny_unknown_fields)]
446struct ScanProfileDefenseV1 {
447    enforcement_enabled: bool,
448}
449
450fn baseline_web_assessment_limits() -> Result<WebAssessmentLimits, WebAssessmentLimitsError> {
451    let defaults = WebAssessmentLimits::default();
452    defaults
453        .with_max_subjects(1)?
454        .with_max_discovery_depth(0)?
455        .with_max_references_per_document(0)?
456        .with_max_canonical_url_bytes(defaults.max_canonical_url_bytes())?
457        .with_max_retained_url_bytes(defaults.max_canonical_url_bytes())?
458        .with_max_forms(0)?
459        .with_max_controls_per_form(0)?
460        .with_max_query_parameter_names(0)?
461        .with_max_total_requests(BASELINE_SCAN_PROFILE_MAX_TOTAL_REQUESTS)?
462        .with_max_response_body_bytes(defaults.max_response_body_bytes())?
463        .with_max_total_response_bytes(BASELINE_SCAN_PROFILE_MAX_TOTAL_RESPONSE_BYTES)?
464        .with_max_wall_time(Duration::from_millis(
465            BASELINE_SCAN_PROFILE_MAX_WALL_TIME_MS,
466        ))?
467        .with_max_active_verifications(DEFAULT_MAX_ACTIVE_VERIFICATIONS)
468}
469
470/// A validated `venom.scan-profile/v1` product profile.
471///
472/// The type has no [`Default`] implementation: callers must deliberately
473/// choose `baseline` or `web-review`. Deserialization is fail-closed and
474/// rejects every unknown field, identifier, schema version, invalid limit, or
475/// capability/scope mismatch.
476#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
477#[serde(deny_unknown_fields)]
478pub struct ScanProfileV1 {
479    schema: ScanProfileSchemaV1,
480    profile: BuiltInScanProfile,
481    scope: ScanProfileScope,
482    capabilities: ScanProfileCapabilitiesV1,
483    limits: ScanProfileLimitsV1,
484    defense: ScanProfileDefenseV1,
485}
486
487impl ScanProfileV1 {
488    /// Constructs the conservative, single-resource built-in profile.
489    pub fn baseline() -> Result<Self, ScanProfileV1Error> {
490        let limits = baseline_web_assessment_limits()?;
491        Self::from_parts(
492            BuiltInScanProfile::Baseline,
493            ScanProfileScope::SingleResource,
494            ScanProfileCapabilitiesV1::BASELINE,
495            ScanProfileLimitsV1::from_checked(limits)?,
496            false,
497        )
498    }
499
500    /// Constructs bounded exact-origin review with enforcement disabled.
501    pub fn web_review() -> Result<Self, ScanProfileV1Error> {
502        Self::from_parts(
503            BuiltInScanProfile::WebReview,
504            ScanProfileScope::ExactOrigin,
505            ScanProfileCapabilitiesV1::WEB_REVIEW,
506            ScanProfileLimitsV1::from_checked(WebAssessmentLimits::default())?,
507            false,
508        )
509    }
510
511    /// Constructs one of the exact built-in profiles.
512    pub fn for_builtin(profile: BuiltInScanProfile) -> Result<Self, ScanProfileV1Error> {
513        match profile {
514            BuiltInScanProfile::Baseline => Self::baseline(),
515            BuiltInScanProfile::WebReview => Self::web_review(),
516        }
517    }
518
519    /// Parses an exact built-in identifier and constructs its profile.
520    pub fn for_builtin_id(value: &str) -> Result<Self, ScanProfileSelectionError> {
521        let profile = BuiltInScanProfile::parse_id(value)?;
522        Self::for_builtin(profile).map_err(ScanProfileSelectionError::InvalidProfile)
523    }
524
525    fn from_parts(
526        profile: BuiltInScanProfile,
527        scope: ScanProfileScope,
528        capabilities: ScanProfileCapabilitiesV1,
529        limits: ScanProfileLimitsV1,
530        defense_enforcement_enabled: bool,
531    ) -> Result<Self, ScanProfileV1Error> {
532        let candidate = Self {
533            schema: ScanProfileSchemaV1::V1,
534            profile,
535            scope,
536            capabilities,
537            limits,
538            defense: ScanProfileDefenseV1 {
539                enforcement_enabled: defense_enforcement_enabled,
540            },
541        };
542        candidate.validate()?;
543        Ok(candidate)
544    }
545
546    fn validate(&self) -> Result<(), ScanProfileV1Error> {
547        let expected_scope = self.profile.required_scope();
548        if self.scope != expected_scope {
549            return Err(ScanProfileV1Error::ScopeMismatch {
550                profile: self.profile,
551                expected: expected_scope,
552                actual: self.scope,
553            });
554        }
555        if self.capabilities != self.profile.required_capabilities() {
556            return Err(ScanProfileV1Error::CapabilityManifestMismatch {
557                profile: self.profile,
558            });
559        }
560        if self.defense.enforcement_enabled && self.profile != BuiltInScanProfile::WebReview {
561            return Err(ScanProfileV1Error::DefenseEnforcementNotAllowed {
562                profile: self.profile,
563            });
564        }
565        if self.profile == BuiltInScanProfile::Baseline {
566            self.validate_baseline_limits()?;
567        }
568        Ok(())
569    }
570
571    fn validate_baseline_limits(&self) -> Result<(), ScanProfileV1Error> {
572        let limits = &self.limits;
573        let expected = baseline_web_assessment_limits()?;
574        let invalid_dimension = if limits.max_subjects() != expected.max_subjects() {
575            Some("max_subjects")
576        } else if limits.max_discovery_depth() != expected.max_discovery_depth() {
577            Some("max_discovery_depth")
578        } else if limits.max_references_per_document() != expected.max_references_per_document() {
579            Some("max_references_per_document")
580        } else if limits.max_canonical_url_bytes() != expected.max_canonical_url_bytes() {
581            Some("max_canonical_url_bytes")
582        } else if limits.max_retained_url_bytes() != expected.max_retained_url_bytes() {
583            Some("max_retained_url_bytes")
584        } else if limits.max_forms() != expected.max_forms() {
585            Some("max_forms")
586        } else if limits.max_controls_per_form() != expected.max_controls_per_form() {
587            Some("max_controls_per_form")
588        } else if limits.max_query_parameter_names() != expected.max_query_parameter_names() {
589            Some("max_query_parameter_names")
590        } else if limits.max_total_requests() != expected.max_total_requests() {
591            Some("max_total_requests")
592        } else if limits.max_response_body_bytes() != expected.max_response_body_bytes() {
593            Some("max_response_body_bytes")
594        } else if limits.max_total_response_bytes() != expected.max_total_response_bytes() {
595            Some("max_total_response_bytes")
596        } else if limits.max_wall_time_ms() != duration_millis(expected.max_wall_time()) {
597            Some("max_wall_time_ms")
598        } else if limits.max_active_verifications() != expected.max_active_verifications() {
599            Some("max_active_verifications")
600        } else {
601            None
602        };
603        match invalid_dimension {
604            Some(dimension) => Err(ScanProfileV1Error::BaselineLimitMismatch { dimension }),
605            None => Ok(()),
606        }
607    }
608
609    /// Returns the exact schema identifier.
610    pub const fn schema(&self) -> &'static str {
611        match self.schema {
612            ScanProfileSchemaV1::V1 => SCAN_PROFILE_V1_SCHEMA,
613        }
614    }
615
616    /// Returns the selected built-in profile.
617    pub const fn profile(&self) -> BuiltInScanProfile {
618        self.profile
619    }
620
621    /// Returns the profile's fixed network scope.
622    pub const fn scope(&self) -> ScanProfileScope {
623        self.scope
624    }
625
626    /// Returns the exact executable capability manifest.
627    pub const fn capabilities(&self) -> ScanProfileCapabilitiesV1 {
628        self.capabilities
629    }
630
631    /// Returns the complete serialized limits contract.
632    pub const fn limits(&self) -> &ScanProfileLimitsV1 {
633        &self.limits
634    }
635
636    /// Returns the checked limits consumed by the assessment runtime.
637    pub const fn web_assessment_limits(&self) -> WebAssessmentLimits {
638        self.limits.web_assessment_limits()
639    }
640
641    /// Returns whether defense enforcement was explicitly enabled.
642    pub const fn defense_enforcement_enabled(&self) -> bool {
643        self.defense.enforcement_enabled
644    }
645
646    /// Replaces the complete checked limit envelope.
647    ///
648    /// Every baseline dimension remains fixed to the current conservative CLI
649    /// envelope. `web-review` may use any checked, whole-millisecond envelope.
650    pub fn with_limits(mut self, limits: WebAssessmentLimits) -> Result<Self, ScanProfileV1Error> {
651        self.limits = ScanProfileLimitsV1::from_checked(limits)?;
652        self.validate()?;
653        Ok(self)
654    }
655
656    /// Explicitly enables or disables monotonic defense enforcement.
657    ///
658    /// Enforcement is disabled in both built-in constructors. Baseline rejects
659    /// an attempt to enable it; only `web-review` can opt in.
660    pub fn with_defense_enforcement_enabled(
661        mut self,
662        enabled: bool,
663    ) -> Result<Self, ScanProfileV1Error> {
664        self.defense.enforcement_enabled = enabled;
665        self.validate()?;
666        Ok(self)
667    }
668}
669
670#[derive(Deserialize)]
671#[serde(deny_unknown_fields)]
672struct WireScanProfileV1 {
673    schema: ScanProfileSchemaV1,
674    profile: BuiltInScanProfile,
675    scope: ScanProfileScope,
676    capabilities: WireScanProfileCapabilitiesV1,
677    limits: ScanProfileLimitsV1,
678    defense: ScanProfileDefenseV1,
679}
680
681impl<'de> Deserialize<'de> for ScanProfileV1 {
682    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
683    where
684        D: Deserializer<'de>,
685    {
686        let wire = WireScanProfileV1::deserialize(deserializer)?;
687        let ScanProfileSchemaV1::V1 = wire.schema;
688        Self::from_parts(
689            wire.profile,
690            wire.scope,
691            wire.capabilities.into_capabilities(),
692            wire.limits,
693            wire.defense.enforcement_enabled,
694        )
695        .map_err(D::Error::custom)
696    }
697}
698
699/// Invalid relationship inside a v1 profile.
700#[derive(Debug, Clone, PartialEq, Eq, Error)]
701#[non_exhaustive]
702pub enum ScanProfileV1Error {
703    /// A complete limit envelope violated a compiled assessment ceiling.
704    #[error(transparent)]
705    Limits(#[from] WebAssessmentLimitsError),
706    /// The built-in profile was paired with a different network scope.
707    #[error(
708        "scan profile {profile} requires scope {expected}, but the document selected {actual}"
709    )]
710    ScopeMismatch {
711        /// Selected built-in profile.
712        profile: BuiltInScanProfile,
713        /// Only permitted scope for that profile.
714        expected: ScanProfileScope,
715        /// Scope present in the rejected document.
716        actual: ScanProfileScope,
717    },
718    /// Capability flags did not exactly match executable built-in behavior.
719    #[error(
720        "scan profile {profile} has a capability manifest that does not match executable behavior"
721    )]
722    CapabilityManifestMismatch {
723        /// Selected built-in profile.
724        profile: BuiltInScanProfile,
725    },
726    /// A baseline limit differed from the exact conservative CLI envelope.
727    #[error("baseline scan profile limit {dimension} does not match the built-in envelope")]
728    BaselineLimitMismatch {
729        /// Mismatched, non-secret limit field.
730        dimension: &'static str,
731    },
732    /// A checked wall-time limit could not be represented losslessly on the v1 wire.
733    #[error("scan-profile wall-time limits must use whole-millisecond precision")]
734    SubMillisecondWallTime,
735    /// Defense enforcement was requested for a profile that cannot enforce it.
736    #[error("scan profile {profile} does not permit defense enforcement")]
737    DefenseEnforcementNotAllowed {
738        /// Selected built-in profile.
739        profile: BuiltInScanProfile,
740    },
741}
742
743/// Failure to select or construct a built-in v1 profile.
744#[derive(Debug, Clone, PartialEq, Eq, Error)]
745#[non_exhaustive]
746pub enum ScanProfileSelectionError {
747    /// The supplied profile identifier was not exact.
748    #[error(transparent)]
749    UnknownProfile(#[from] BuiltInScanProfileParseError),
750    /// The selected built-in configuration failed its invariant checks.
751    #[error(transparent)]
752    InvalidProfile(#[from] ScanProfileV1Error),
753}
754
755fn duration_millis(duration: Duration) -> u64 {
756    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
757}
758
759#[cfg(test)]
760mod tests {
761    use serde_json::{json, Value};
762
763    use super::*;
764    use crate::web_runtime::{
765        HARD_MAX_WEB_ASSESSMENT_ACTIVE_VERIFICATIONS, HARD_MAX_WEB_ASSESSMENT_CANONICAL_URL_BYTES,
766        HARD_MAX_WEB_ASSESSMENT_CONTROLS_PER_FORM, HARD_MAX_WEB_ASSESSMENT_DEPTH,
767        HARD_MAX_WEB_ASSESSMENT_FORMS, HARD_MAX_WEB_ASSESSMENT_QUERY_NAMES,
768        HARD_MAX_WEB_ASSESSMENT_REFERENCES_PER_DOCUMENT,
769        HARD_MAX_WEB_ASSESSMENT_RESPONSE_BODY_BYTES, HARD_MAX_WEB_ASSESSMENT_RETAINED_URL_BYTES,
770        HARD_MAX_WEB_ASSESSMENT_SUBJECTS, HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS,
771        HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES, HARD_MAX_WEB_ASSESSMENT_WALL_TIME,
772        WEB_ASSESSMENT_CONCURRENCY,
773    };
774
775    fn profile_value(profile: ScanProfileV1) -> Value {
776        serde_json::to_value(profile).unwrap()
777    }
778
779    fn web_review_value() -> Value {
780        profile_value(ScanProfileV1::web_review().unwrap())
781    }
782
783    fn parse(value: &Value) -> Result<ScanProfileV1, serde_json::Error> {
784        serde_json::from_value(value.clone())
785    }
786
787    fn set_limit(value: &mut Value, name: &str, replacement: Value) {
788        value["limits"][name] = replacement;
789    }
790
791    #[test]
792    fn exact_profile_id_parsing_rejects_case_whitespace_and_historical_names() {
793        assert_eq!(
794            BuiltInScanProfile::parse_id("baseline").unwrap(),
795            BuiltInScanProfile::Baseline
796        );
797        assert_eq!(
798            BuiltInScanProfile::parse_id("web-review").unwrap(),
799            BuiltInScanProfile::WebReview
800        );
801        for rejected in [
802            "Baseline",
803            "BASELINE",
804            " baseline",
805            "baseline ",
806            "web_review",
807            "WEB-REVIEW",
808            "enterprise",
809            "cloud",
810            "aggressive",
811            "stealth",
812            "",
813        ] {
814            assert!(
815                BuiltInScanProfile::parse_id(rejected).is_err(),
816                "{rejected:?}"
817            );
818            assert!(
819                ScanProfileV1::for_builtin_id(rejected).is_err(),
820                "{rejected:?}"
821            );
822        }
823    }
824
825    #[test]
826    fn built_in_profiles_pin_executable_capability_truth() {
827        let baseline = ScanProfileV1::baseline().unwrap();
828        assert_eq!(baseline.schema(), SCAN_PROFILE_V1_SCHEMA);
829        assert_eq!(baseline.profile(), BuiltInScanProfile::Baseline);
830        assert_eq!(baseline.scope(), ScanProfileScope::SingleResource);
831        assert!(baseline.capabilities().standard_web_decision());
832        assert!(!baseline.capabilities().origin_discovery());
833        assert!(!baseline.capabilities().semantic_extraction());
834        assert!(!baseline.capabilities().defense_observation());
835        assert!(!baseline.capabilities().defense_shadow_planning());
836        assert!(!baseline.capabilities().passive_security_review());
837        assert!(!baseline.capabilities().low_risk_differential_review());
838        assert!(!baseline.defense_enforcement_enabled());
839
840        let web_review = ScanProfileV1::web_review().unwrap();
841        assert_eq!(web_review.schema(), SCAN_PROFILE_V1_SCHEMA);
842        assert_eq!(web_review.profile(), BuiltInScanProfile::WebReview);
843        assert_eq!(web_review.scope(), ScanProfileScope::ExactOrigin);
844        assert!(web_review.capabilities().standard_web_decision());
845        assert!(web_review.capabilities().origin_discovery());
846        assert!(web_review.capabilities().semantic_extraction());
847        assert!(web_review.capabilities().defense_observation());
848        assert!(web_review.capabilities().defense_shadow_planning());
849        assert!(web_review.capabilities().passive_security_review());
850        assert!(web_review.capabilities().low_risk_differential_review());
851        assert!(!web_review.defense_enforcement_enabled());
852    }
853
854    #[test]
855    fn built_in_limits_are_exact_and_share_fixed_concurrency() {
856        assert_eq!(BASELINE_SCAN_PROFILE_MAX_TOTAL_REQUESTS, 16);
857        assert_eq!(BASELINE_SCAN_PROFILE_MAX_WALL_TIME_MS, 60_000);
858        assert_eq!(BASELINE_SCAN_PROFILE_MAX_TOTAL_RESPONSE_BYTES, 1024 * 1024);
859
860        let baseline = ScanProfileV1::baseline().unwrap();
861        let limits = baseline.limits();
862        assert_eq!(limits.max_subjects(), 1);
863        assert_eq!(limits.max_discovery_depth(), 0);
864        assert_eq!(limits.max_references_per_document(), 0);
865        assert_eq!(
866            limits.max_retained_url_bytes(),
867            limits.max_canonical_url_bytes()
868        );
869        assert_eq!(limits.max_forms(), 0);
870        assert_eq!(limits.max_controls_per_form(), 0);
871        assert_eq!(limits.max_query_parameter_names(), 0);
872        assert_eq!(
873            limits.max_total_requests(),
874            BASELINE_SCAN_PROFILE_MAX_TOTAL_REQUESTS
875        );
876        assert_eq!(
877            limits.max_total_response_bytes(),
878            BASELINE_SCAN_PROFILE_MAX_TOTAL_RESPONSE_BYTES
879        );
880        assert_eq!(
881            limits.max_wall_time_ms(),
882            BASELINE_SCAN_PROFILE_MAX_WALL_TIME_MS
883        );
884        assert_eq!(
885            limits.max_active_verifications(),
886            DEFAULT_MAX_ACTIVE_VERIFICATIONS
887        );
888        assert_eq!(limits.concurrency(), WEB_ASSESSMENT_CONCURRENCY);
889
890        let expected = WebAssessmentLimits::default();
891        let web_review = ScanProfileV1::web_review().unwrap();
892        assert_eq!(web_review.web_assessment_limits(), expected);
893        assert_eq!(
894            web_review.limits().max_total_requests(),
895            expected.max_total_requests()
896        );
897        assert_eq!(
898            web_review.limits().max_wall_time_ms(),
899            duration_millis(expected.max_wall_time())
900        );
901        assert_eq!(
902            web_review.limits().concurrency(),
903            WEB_ASSESSMENT_CONCURRENCY
904        );
905    }
906
907    #[test]
908    fn both_built_ins_round_trip_without_defaults_or_hidden_fields() {
909        for profile in [
910            ScanProfileV1::baseline().unwrap(),
911            ScanProfileV1::web_review().unwrap(),
912        ] {
913            let encoded = serde_json::to_string(&profile).unwrap();
914            assert!(!encoded.contains("checked"));
915            assert!(!encoded.contains("concurrency"));
916            let decoded: ScanProfileV1 = serde_json::from_str(&encoded).unwrap();
917            assert_eq!(decoded, profile);
918        }
919    }
920
921    #[test]
922    fn unknown_schema_versions_and_version_field_fail_closed() {
923        let mut value = web_review_value();
924        value["schema"] = json!("venom.scan-profile/v2");
925        assert!(parse(&value).is_err());
926
927        let mut value = web_review_value();
928        value["schema"] = json!("venom.scan-profile/V1");
929        assert!(parse(&value).is_err());
930
931        let mut value = web_review_value();
932        value["version"] = json!(1);
933        assert!(parse(&value).is_err());
934    }
935
936    #[test]
937    fn unknown_profile_scope_and_case_fail_closed_on_wire() {
938        for replacement in ["enterprise", "WEB-REVIEW", " web-review"] {
939            let mut value = web_review_value();
940            value["profile"] = json!(replacement);
941            assert!(parse(&value).is_err(), "{replacement:?}");
942        }
943        for replacement in ["origin", "ExactOrigin", "exact-origin "] {
944            let mut value = web_review_value();
945            value["scope"] = json!(replacement);
946            assert!(parse(&value).is_err(), "{replacement:?}");
947        }
948    }
949
950    #[test]
951    fn invalid_wire_scalars_never_echo_rejected_secret_shaped_text() {
952        const REJECTED: &str = "Bearer profile-secret-that-must-not-be-echoed";
953
954        for field in ["schema", "profile", "scope"] {
955            let mut value = web_review_value();
956            value[field] = json!(REJECTED);
957            let error = parse(&value).unwrap_err();
958            for rendered in [error.to_string(), format!("{error:?}")] {
959                assert!(!rendered.contains(REJECTED), "{field}: {rendered}");
960            }
961        }
962    }
963
964    #[test]
965    fn every_object_level_rejects_unknown_fields() {
966        for (field, replacement) in [
967            ("target", json!("https://outside.invalid/")),
968            ("headers", json!({"authorization": "not-accepted"})),
969            ("transport", json!({"redirects": true})),
970            ("concurrency", json!(2)),
971            ("secret", json!("not-accepted")),
972            ("unexpected", json!(true)),
973        ] {
974            let mut top = web_review_value();
975            top[field] = replacement;
976            assert!(parse(&top).is_err(), "{field}");
977        }
978
979        let mut capabilities = web_review_value();
980        capabilities["capabilities"]["unexpected"] = json!(true);
981        assert!(parse(&capabilities).is_err());
982
983        let mut limits = web_review_value();
984        limits["limits"]["concurrency"] = json!(2);
985        assert!(parse(&limits).is_err());
986
987        let mut defense = web_review_value();
988        defense["defense"]["strategy"] = json!("evasion");
989        assert!(parse(&defense).is_err());
990    }
991
992    #[test]
993    fn every_required_top_level_and_nested_field_must_be_present() {
994        for field in [
995            "schema",
996            "profile",
997            "scope",
998            "capabilities",
999            "limits",
1000            "defense",
1001        ] {
1002            let mut value = web_review_value();
1003            value.as_object_mut().unwrap().remove(field);
1004            assert!(parse(&value).is_err(), "{field}");
1005        }
1006
1007        for field in [
1008            "standard_web_decision",
1009            "origin_discovery",
1010            "semantic_extraction",
1011            "defense_observation",
1012            "defense_shadow_planning",
1013            "passive_security_review",
1014            "low_risk_differential_review",
1015        ] {
1016            let mut value = web_review_value();
1017            value["capabilities"].as_object_mut().unwrap().remove(field);
1018            assert!(parse(&value).is_err(), "{field}");
1019        }
1020
1021        let mut value = web_review_value();
1022        value["defense"]
1023            .as_object_mut()
1024            .unwrap()
1025            .remove("enforcement_enabled");
1026        assert!(parse(&value).is_err());
1027    }
1028
1029    #[test]
1030    fn complete_limit_object_has_no_implicit_defaults() {
1031        for field in [
1032            "max_subjects",
1033            "max_discovery_depth",
1034            "max_references_per_document",
1035            "max_canonical_url_bytes",
1036            "max_retained_url_bytes",
1037            "max_forms",
1038            "max_controls_per_form",
1039            "max_query_parameter_names",
1040            "max_total_requests",
1041            "max_response_body_bytes",
1042            "max_total_response_bytes",
1043            "max_wall_time_ms",
1044            "max_active_verifications",
1045        ] {
1046            let mut value = web_review_value();
1047            value["limits"].as_object_mut().unwrap().remove(field);
1048            assert!(parse(&value).is_err(), "{field}");
1049        }
1050    }
1051
1052    #[test]
1053    fn every_compiled_limit_rejects_hard_maximum_plus_one() {
1054        let cases = [
1055            ("max_subjects", json!(HARD_MAX_WEB_ASSESSMENT_SUBJECTS + 1)),
1056            (
1057                "max_discovery_depth",
1058                json!(u32::from(HARD_MAX_WEB_ASSESSMENT_DEPTH) + 1),
1059            ),
1060            (
1061                "max_references_per_document",
1062                json!(HARD_MAX_WEB_ASSESSMENT_REFERENCES_PER_DOCUMENT + 1),
1063            ),
1064            (
1065                "max_canonical_url_bytes",
1066                json!(HARD_MAX_WEB_ASSESSMENT_CANONICAL_URL_BYTES + 1),
1067            ),
1068            (
1069                "max_retained_url_bytes",
1070                json!(HARD_MAX_WEB_ASSESSMENT_RETAINED_URL_BYTES + 1),
1071            ),
1072            ("max_forms", json!(HARD_MAX_WEB_ASSESSMENT_FORMS + 1)),
1073            (
1074                "max_controls_per_form",
1075                json!(HARD_MAX_WEB_ASSESSMENT_CONTROLS_PER_FORM + 1),
1076            ),
1077            (
1078                "max_query_parameter_names",
1079                json!(HARD_MAX_WEB_ASSESSMENT_QUERY_NAMES + 1),
1080            ),
1081            (
1082                "max_total_requests",
1083                json!(u64::from(HARD_MAX_WEB_ASSESSMENT_TOTAL_REQUESTS) + 1),
1084            ),
1085            (
1086                "max_response_body_bytes",
1087                json!(HARD_MAX_WEB_ASSESSMENT_RESPONSE_BODY_BYTES + 1),
1088            ),
1089            (
1090                "max_total_response_bytes",
1091                json!(HARD_MAX_WEB_ASSESSMENT_TOTAL_RESPONSE_BYTES + 1),
1092            ),
1093            (
1094                "max_wall_time_ms",
1095                json!(duration_millis(HARD_MAX_WEB_ASSESSMENT_WALL_TIME) + 1),
1096            ),
1097            (
1098                "max_active_verifications",
1099                json!(u32::from(HARD_MAX_WEB_ASSESSMENT_ACTIVE_VERIFICATIONS) + 1),
1100            ),
1101        ];
1102
1103        for (field, replacement) in cases {
1104            let mut value = web_review_value();
1105            set_limit(&mut value, field, replacement);
1106            assert!(parse(&value).is_err(), "{field}");
1107        }
1108    }
1109
1110    #[test]
1111    fn runtime_required_nonzero_limits_reject_zero() {
1112        for field in [
1113            "max_subjects",
1114            "max_canonical_url_bytes",
1115            "max_retained_url_bytes",
1116            "max_response_body_bytes",
1117            "max_total_response_bytes",
1118        ] {
1119            let mut value = web_review_value();
1120            set_limit(&mut value, field, json!(0));
1121            assert!(parse(&value).is_err(), "{field}");
1122        }
1123    }
1124
1125    #[test]
1126    fn fail_closed_zero_limits_remain_valid_where_runtime_contract_allows_them() {
1127        let mut value = web_review_value();
1128        for field in [
1129            "max_discovery_depth",
1130            "max_references_per_document",
1131            "max_forms",
1132            "max_controls_per_form",
1133            "max_query_parameter_names",
1134            "max_total_requests",
1135            "max_wall_time_ms",
1136            "max_active_verifications",
1137        ] {
1138            set_limit(&mut value, field, json!(0));
1139        }
1140        let profile = parse(&value).unwrap();
1141        assert_eq!(profile.limits().max_discovery_depth(), 0);
1142        assert_eq!(profile.limits().max_total_requests(), 0);
1143        assert_eq!(profile.limits().max_wall_time_ms(), 0);
1144        assert_eq!(profile.limits().max_active_verifications(), 0);
1145    }
1146
1147    #[test]
1148    fn baseline_rejects_any_limit_that_differs_from_its_exact_cli_envelope() {
1149        for (field, replacement) in [
1150            ("max_subjects", json!(2)),
1151            ("max_discovery_depth", json!(1)),
1152            ("max_references_per_document", json!(1)),
1153            ("max_canonical_url_bytes", json!(1)),
1154            ("max_retained_url_bytes", json!(1)),
1155            ("max_forms", json!(1)),
1156            ("max_controls_per_form", json!(1)),
1157            ("max_query_parameter_names", json!(1)),
1158            ("max_total_requests", json!(0)),
1159            ("max_response_body_bytes", json!(1)),
1160            ("max_total_response_bytes", json!(1)),
1161            ("max_wall_time_ms", json!(0)),
1162            ("max_active_verifications", json!(0)),
1163        ] {
1164            let mut value = profile_value(ScanProfileV1::baseline().unwrap());
1165            set_limit(&mut value, field, replacement);
1166            assert!(parse(&value).is_err(), "{field}");
1167        }
1168    }
1169
1170    #[test]
1171    fn profile_scope_and_capability_combinations_are_not_user_selectable() {
1172        let mut baseline_scope = profile_value(ScanProfileV1::baseline().unwrap());
1173        baseline_scope["scope"] = json!("exact-origin");
1174        assert!(parse(&baseline_scope).is_err());
1175
1176        let mut web_scope = web_review_value();
1177        web_scope["scope"] = json!("single-resource");
1178        assert!(parse(&web_scope).is_err());
1179
1180        for built_in in [
1181            ScanProfileV1::baseline().unwrap(),
1182            ScanProfileV1::web_review().unwrap(),
1183        ] {
1184            for capability in [
1185                "standard_web_decision",
1186                "origin_discovery",
1187                "semantic_extraction",
1188                "defense_observation",
1189                "defense_shadow_planning",
1190                "passive_security_review",
1191                "low_risk_differential_review",
1192            ] {
1193                let mut value = profile_value(built_in.clone());
1194                let current = value["capabilities"][capability].as_bool().unwrap();
1195                value["capabilities"][capability] = json!(!current);
1196                assert!(parse(&value).is_err(), "{capability}");
1197            }
1198        }
1199    }
1200
1201    #[test]
1202    fn defense_enforcement_is_default_off_and_requires_explicit_web_review_opt_in() {
1203        let baseline = ScanProfileV1::baseline().unwrap();
1204        assert!(baseline.with_defense_enforcement_enabled(true).is_err());
1205
1206        let web_review = ScanProfileV1::web_review().unwrap();
1207        assert!(!web_review.defense_enforcement_enabled());
1208        let enabled = web_review.with_defense_enforcement_enabled(true).unwrap();
1209        assert!(enabled.defense_enforcement_enabled());
1210        let round_trip: ScanProfileV1 =
1211            serde_json::from_value(profile_value(enabled.clone())).unwrap();
1212        assert_eq!(round_trip, enabled);
1213
1214        let mut baseline_wire = profile_value(ScanProfileV1::baseline().unwrap());
1215        baseline_wire["defense"]["enforcement_enabled"] = json!(true);
1216        assert!(parse(&baseline_wire).is_err());
1217    }
1218
1219    #[test]
1220    fn checked_limit_replacement_preserves_profile_invariants() {
1221        let web_review = ScanProfileV1::web_review().unwrap();
1222        let narrowed = WebAssessmentLimits::default()
1223            .with_max_subjects(2)
1224            .unwrap()
1225            .with_max_total_requests(3)
1226            .unwrap();
1227        let web_review = web_review.with_limits(narrowed).unwrap();
1228        assert_eq!(web_review.limits().max_subjects(), 2);
1229        assert_eq!(web_review.limits().max_total_requests(), 3);
1230
1231        let baseline = ScanProfileV1::baseline().unwrap();
1232        assert!(baseline.with_limits(narrowed).is_err());
1233    }
1234
1235    #[test]
1236    fn checked_limit_conversion_requires_lossless_millisecond_precision() {
1237        let sub_millisecond = WebAssessmentLimits::default()
1238            .with_max_wall_time(Duration::from_nanos(1))
1239            .unwrap();
1240        assert!(matches!(
1241            ScanProfileLimitsV1::from_web_assessment_limits(sub_millisecond),
1242            Err(ScanProfileV1Error::SubMillisecondWallTime)
1243        ));
1244        assert!(matches!(
1245            ScanProfileV1::web_review()
1246                .unwrap()
1247                .with_limits(sub_millisecond),
1248            Err(ScanProfileV1Error::SubMillisecondWallTime)
1249        ));
1250
1251        let exact_milliseconds = WebAssessmentLimits::default()
1252            .with_max_wall_time(Duration::from_millis(1_234))
1253            .unwrap();
1254        let profile = ScanProfileV1::web_review()
1255            .unwrap()
1256            .with_limits(exact_milliseconds)
1257            .unwrap();
1258        let encoded = serde_json::to_string(&profile).unwrap();
1259        let decoded: ScanProfileV1 = serde_json::from_str(&encoded).unwrap();
1260        assert_eq!(decoded, profile);
1261        assert_eq!(decoded.web_assessment_limits(), exact_milliseconds);
1262    }
1263}