Skip to main content

bhtune_runtime/config/
demo.rs

1#![allow(rustdoc::broken_intra_doc_links)]
2
3use serde::{Deserialize, Serialize};
4use std::net::IpAddr;
5
6use super::model::BhtuneConfig;
7
8/// Fixed lifetime of an anonymous public Demo visitor session.
9pub const DEMO_SESSION_TTL_SECS: u64 = 86_400;
10
11/// Fixed simulator polling interval used by public Demo tunes.
12pub const DEMO_POLL_INTERVAL_MS: u64 = 200;
13
14/// Fixed whole-run timeout used by public Demo tunes.
15pub const DEMO_RUN_TIMEOUT_SECS: u64 = 30;
16
17/// Maximum number of Demo tunes that may be active across all visitors.
18pub const DEMO_MAX_ACTIVE_RUNS_GLOBAL: u32 = 8;
19
20/// Maximum number of Demo tunes that may be active for one visitor.
21pub const DEMO_MAX_ACTIVE_RUNS_PER_VISITOR: u32 = 1;
22
23/// Accepted Demo tune starts allowed for one session token in a quota window.
24pub const DEMO_ACCEPTED_STARTS_PER_TOKEN: u32 = 6;
25
26/// Accepted Demo tune starts allowed for one client IP in a quota window.
27pub const DEMO_ACCEPTED_STARTS_PER_CLIENT_IP: u32 = 6;
28
29/// Fixed window used by the accepted-start quotas.
30pub const DEMO_ACCEPTED_START_WINDOW_SECS: u64 = 600;
31
32/// Maximum accepted demo runs retained per session, independent of active-run concurrency.
33pub const DEMO_MAX_RUNS_PER_SESSION: u32 = 10;
34
35/// Maximum number of completed Demo runs retained for one visitor.
36pub const DEMO_RETAINED_RUNS_PER_VISITOR: u32 = 10;
37
38/// Maximum number of current Demo-owned `tune_runs` rows in the database.
39pub const DEMO_MAX_TUNE_RUN_ROWS_GLOBAL: u32 = 5_000;
40
41/// Maximum JSON request-body size accepted by the Demo API.
42pub const DEMO_MAX_JSON_BODY_BYTES: u64 = 32_768;
43
44/// Maximum number of simultaneous SSE streams for one visitor.
45pub const DEMO_MAX_SSE_PER_VISITOR: u32 = 2;
46
47/// Maximum number of simultaneous Demo SSE streams across all visitors.
48pub const DEMO_MAX_SSE_GLOBAL: u32 = 32;
49
50/// Absolute lifetime of one Demo SSE stream.
51pub const DEMO_SSE_LIFETIME_SECS: u64 = 45;
52
53/// Maximum number of ordinary Demo API requests processed concurrently.
54pub const DEMO_ORDINARY_REQUEST_CONCURRENCY: u32 = 64;
55
56/// Timeout applied to an ordinary, non-streaming Demo API request.
57pub const DEMO_ORDINARY_REQUEST_TIMEOUT_SECS: u64 = 10;
58
59/// Interval between expired-session and excess-history cleanup passes.
60pub const DEMO_CLEANUP_INTERVAL_SECS: u64 = 300;
61
62/// Built-in template exposed by the public Demo.
63pub const DEMO_TEMPLATE_NAME: &str = "Yokogawa CentumVP";
64
65/// Fixed persisted/display label used by public Demo runs.
66///
67/// This is deliberately not an OPC or simulator driver tag. The simulator still uses its
68/// internal `Sim.PV`/`Sim.MV` tags; this value is the stable run identity shown to visitors.
69pub const DEMO_TAG_NAME: &str = "Simulator demo";
70
71/// Default PV and MV lower bound used by public Demo runs.
72pub const DEMO_RANGE_LOW: f32 = 0.0;
73
74/// Default PV and MV upper bound used by public Demo runs.
75pub const DEMO_RANGE_HIGH: f32 = 100.0;
76
77/// Minimum PV/MV range endpoint accepted by the public Demo.
78pub const DEMO_RANGE_ENDPOINT_MIN: f32 = -1_000.0;
79
80/// Maximum PV/MV range endpoint accepted by the public Demo.
81pub const DEMO_RANGE_ENDPOINT_MAX: f32 = 1_000.0;
82
83/// Minimum PV/MV span accepted by the public Demo.
84pub const DEMO_RANGE_SPAN_MIN: f32 = 1.0;
85
86/// Maximum PV/MV span accepted by the public Demo.
87pub const DEMO_RANGE_SPAN_MAX: f32 = 1_000.0;
88
89/// Minimum positive process gain accepted by the public Demo.
90pub const DEMO_SIM_GAIN_MIN: f32 = 0.1;
91
92/// Maximum positive process gain accepted by the public Demo.
93pub const DEMO_SIM_GAIN_MAX: f32 = 5.0;
94
95/// Minimum simulator time constant accepted by the public Demo.
96pub const DEMO_SIM_TAU_MIN: f32 = 0.05;
97
98/// Maximum simulator time constant accepted by the public Demo.
99pub const DEMO_SIM_TAU_MAX: f32 = 5.0;
100
101/// Minimum simulator dead time accepted by the public Demo.
102pub const DEMO_SIM_DEAD_TIME_MIN: f32 = 0.0;
103
104/// Maximum simulator dead time accepted by the public Demo.
105pub const DEMO_SIM_DEAD_TIME_MAX: f32 = 2.0;
106
107/// Minimum simulator noise amplitude accepted by the public Demo.
108pub const DEMO_SIM_NOISE_MIN: f32 = 0.0;
109
110/// Maximum simulator noise amplitude as a fraction of the configured PV span.
111pub const DEMO_SIM_NOISE_MAX_PV_SPAN_FRACTION: f32 = 0.05;
112
113/// Largest simulator seed accepted by the public Demo.
114pub const DEMO_SIM_SEED_MAX: u64 = i32::MAX as u64;
115
116/// Minimum relay amplitude accepted by the public Demo.
117pub const DEMO_RELAY_AMP_MIN: f32 = 1.0;
118
119/// Maximum relay amplitude accepted by the public Demo.
120pub const DEMO_RELAY_AMP_MAX: f32 = 20.0;
121
122/// Default relay amplitude used by the Demo form.
123pub const DEMO_RELAY_AMP_DEFAULT: f32 = 10.0;
124
125/// Minimum relay-cycle skip count accepted by the public Demo.
126pub const DEMO_CYCLES_SKIP_MIN: u32 = 0;
127
128/// Maximum relay-cycle skip count accepted by the public Demo.
129pub const DEMO_CYCLES_SKIP_MAX: u32 = 2;
130
131/// Default relay-cycle skip count used by the Demo form.
132pub const DEMO_CYCLES_SKIP_DEFAULT: u32 = 1;
133
134/// Minimum relay-cycle count accepted by the public Demo.
135pub const DEMO_CYCLES_COUNT_MIN: u32 = 1;
136
137/// Maximum relay-cycle count accepted by the public Demo.
138pub const DEMO_CYCLES_COUNT_MAX: u32 = 3;
139
140/// Default relay-cycle count used by the Demo form.
141pub const DEMO_CYCLES_COUNT_DEFAULT: u32 = 2;
142
143/// Minimum switch noise-protection delay accepted by the public Demo.
144pub const DEMO_NOISE_PROTECTION_SECS_MIN: u32 = 0;
145
146/// Maximum switch noise-protection delay accepted by the public Demo.
147pub const DEMO_NOISE_PROTECTION_SECS_MAX: u32 = 3;
148
149/// Default switch noise-protection delay used by the Demo form.
150pub const DEMO_NOISE_PROTECTION_SECS_DEFAULT: u32 = 0;
151
152/// Default simulator process gain used by the Demo form.
153pub const DEMO_SIM_GAIN_DEFAULT: f32 = 1.0;
154
155/// Default simulator time constant used by the Demo form.
156pub const DEMO_SIM_TAU_DEFAULT: f32 = 0.5;
157
158/// Default simulator dead time used by the Demo form.
159pub const DEMO_SIM_DEAD_TIME_DEFAULT: f32 = 1.0;
160
161/// Default simulator noise amplitude used by the Demo form.
162pub const DEMO_SIM_NOISE_DEFAULT: f32 = 0.0;
163
164/// Default simulator random seed used by the Demo form.
165pub const DEMO_SIM_SEED_DEFAULT: u64 = 0;
166
167/// Default simulator PV and MV starting value used by the Demo form.
168pub const DEMO_SIM_INITIAL_VALUE_DEFAULT: f32 = 50.0;
169
170/// Name of the host-only anonymous Demo session cookie.
171pub const DEMO_COOKIE_NAME: &str = "__Host-bhtune_demo_session";
172
173/// Runtime server exposure mode. Full mode preserves the normal live-plant API; Demo mode
174/// is an explicitly restricted, simulator-only surface intended for public demonstrations.
175#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
176#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
177#[serde(rename_all = "lowercase")]
178pub enum ServerMode {
179    #[default]
180    Full,
181    Demo,
182}
183
184/// Limits applied to the public simulator-only demo surface.
185#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
186#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
187pub struct DemoPolicy {
188    /// Anonymous visitor-session lifetime.
189    #[cfg_attr(feature = "schemars", schemars(range(min = 86_400, max = 86_400)))]
190    pub session_ttl_secs: u64,
191    /// Simulator polling interval.
192    #[cfg_attr(feature = "schemars", schemars(range(min = 200, max = 200)))]
193    pub poll_interval_ms: u64,
194    /// Whole-run timeout.
195    #[cfg_attr(feature = "schemars", schemars(range(min = 30, max = 30)))]
196    pub run_timeout_secs: u64,
197    /// Active Demo tune limit across all visitors.
198    #[cfg_attr(feature = "schemars", schemars(range(min = 8, max = 8)))]
199    pub max_active_runs_global: u32,
200    /// Active Demo tune limit for one visitor.
201    #[cfg_attr(feature = "schemars", schemars(range(min = 1, max = 1)))]
202    pub max_active_runs_per_visitor: u32,
203    /// Accepted starts for one session token in the quota window.
204    #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
205    pub accepted_starts_per_token: u32,
206    /// Accepted starts for one client IP in the quota window.
207    #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
208    pub accepted_starts_per_client_ip: u32,
209    /// Window shared by both accepted-start quotas.
210    #[cfg_attr(feature = "schemars", schemars(range(min = 600, max = 600)))]
211    pub accepted_start_window_secs: u64,
212    /// Completed runs retained for one visitor.
213    #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
214    pub retained_runs_per_visitor: u32,
215    /// Maximum total demo runs accepted for one visitor.
216    pub max_runs_per_session: u32,
217    /// Current Demo-owned `tune_runs` row limit across all visitors.
218    #[cfg_attr(feature = "schemars", schemars(range(min = 5_000, max = 5_000)))]
219    pub max_tune_run_rows_global: u32,
220    /// Maximum JSON request-body size.
221    #[cfg_attr(feature = "schemars", schemars(range(min = 32_768, max = 32_768)))]
222    pub max_json_body_bytes: u64,
223    /// Simultaneous SSE streams for one visitor.
224    #[cfg_attr(feature = "schemars", schemars(range(min = 2, max = 2)))]
225    pub max_sse_per_visitor: u32,
226    /// Simultaneous Demo SSE streams across all visitors.
227    #[cfg_attr(feature = "schemars", schemars(range(min = 32, max = 32)))]
228    pub max_sse_global: u32,
229    /// Absolute lifetime of one Demo SSE stream.
230    #[cfg_attr(feature = "schemars", schemars(range(min = 45, max = 45)))]
231    pub sse_lifetime_secs: u64,
232    /// Concurrent ordinary, non-streaming Demo API requests.
233    #[cfg_attr(feature = "schemars", schemars(range(min = 64, max = 64)))]
234    pub ordinary_request_concurrency: u32,
235    /// Timeout for an ordinary, non-streaming Demo API request.
236    #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
237    pub ordinary_request_timeout_secs: u64,
238    /// Interval between Demo cleanup passes.
239    #[cfg_attr(feature = "schemars", schemars(range(min = 300, max = 300)))]
240    pub cleanup_interval_secs: u64,
241}
242
243impl Default for DemoPolicy {
244    fn default() -> Self {
245        Self {
246            session_ttl_secs: DEMO_SESSION_TTL_SECS,
247            poll_interval_ms: DEMO_POLL_INTERVAL_MS,
248            run_timeout_secs: DEMO_RUN_TIMEOUT_SECS,
249            max_active_runs_global: DEMO_MAX_ACTIVE_RUNS_GLOBAL,
250            max_active_runs_per_visitor: DEMO_MAX_ACTIVE_RUNS_PER_VISITOR,
251            accepted_starts_per_token: DEMO_ACCEPTED_STARTS_PER_TOKEN,
252            accepted_starts_per_client_ip: DEMO_ACCEPTED_STARTS_PER_CLIENT_IP,
253            accepted_start_window_secs: DEMO_ACCEPTED_START_WINDOW_SECS,
254            retained_runs_per_visitor: DEMO_RETAINED_RUNS_PER_VISITOR,
255            max_runs_per_session: DEMO_MAX_RUNS_PER_SESSION,
256            max_tune_run_rows_global: DEMO_MAX_TUNE_RUN_ROWS_GLOBAL,
257            max_json_body_bytes: DEMO_MAX_JSON_BODY_BYTES,
258            max_sse_per_visitor: DEMO_MAX_SSE_PER_VISITOR,
259            max_sse_global: DEMO_MAX_SSE_GLOBAL,
260            sse_lifetime_secs: DEMO_SSE_LIFETIME_SECS,
261            ordinary_request_concurrency: DEMO_ORDINARY_REQUEST_CONCURRENCY,
262            ordinary_request_timeout_secs: DEMO_ORDINARY_REQUEST_TIMEOUT_SECS,
263            cleanup_interval_secs: DEMO_CLEANUP_INTERVAL_SECS,
264        }
265    }
266}
267
268impl DemoPolicy {
269    pub fn validate(&self) -> Result<(), String> {
270        validate_demo_value(
271            "session_ttl_secs",
272            self.session_ttl_secs,
273            DEMO_SESSION_TTL_SECS,
274        )?;
275        validate_demo_value(
276            "poll_interval_ms",
277            self.poll_interval_ms,
278            DEMO_POLL_INTERVAL_MS,
279        )?;
280        validate_demo_value(
281            "run_timeout_secs",
282            self.run_timeout_secs,
283            DEMO_RUN_TIMEOUT_SECS,
284        )?;
285        validate_demo_value(
286            "max_active_runs_global",
287            self.max_active_runs_global,
288            DEMO_MAX_ACTIVE_RUNS_GLOBAL,
289        )?;
290        validate_demo_value(
291            "max_active_runs_per_visitor",
292            self.max_active_runs_per_visitor,
293            DEMO_MAX_ACTIVE_RUNS_PER_VISITOR,
294        )?;
295        validate_demo_value(
296            "accepted_starts_per_token",
297            self.accepted_starts_per_token,
298            DEMO_ACCEPTED_STARTS_PER_TOKEN,
299        )?;
300        validate_demo_value(
301            "accepted_starts_per_client_ip",
302            self.accepted_starts_per_client_ip,
303            DEMO_ACCEPTED_STARTS_PER_CLIENT_IP,
304        )?;
305        validate_demo_value(
306            "accepted_start_window_secs",
307            self.accepted_start_window_secs,
308            DEMO_ACCEPTED_START_WINDOW_SECS,
309        )?;
310        validate_demo_value(
311            "retained_runs_per_visitor",
312            self.retained_runs_per_visitor,
313            DEMO_RETAINED_RUNS_PER_VISITOR,
314        )?;
315        validate_demo_value(
316            "max_runs_per_session",
317            self.max_runs_per_session,
318            DEMO_MAX_RUNS_PER_SESSION,
319        )?;
320        validate_demo_value(
321            "max_tune_run_rows_global",
322            self.max_tune_run_rows_global,
323            DEMO_MAX_TUNE_RUN_ROWS_GLOBAL,
324        )?;
325        validate_demo_value(
326            "max_json_body_bytes",
327            self.max_json_body_bytes,
328            DEMO_MAX_JSON_BODY_BYTES,
329        )?;
330        validate_demo_value(
331            "max_sse_per_visitor",
332            self.max_sse_per_visitor,
333            DEMO_MAX_SSE_PER_VISITOR,
334        )?;
335        validate_demo_value("max_sse_global", self.max_sse_global, DEMO_MAX_SSE_GLOBAL)?;
336        validate_demo_value(
337            "sse_lifetime_secs",
338            self.sse_lifetime_secs,
339            DEMO_SSE_LIFETIME_SECS,
340        )?;
341        validate_demo_value(
342            "ordinary_request_concurrency",
343            self.ordinary_request_concurrency,
344            DEMO_ORDINARY_REQUEST_CONCURRENCY,
345        )?;
346        validate_demo_value(
347            "ordinary_request_timeout_secs",
348            self.ordinary_request_timeout_secs,
349            DEMO_ORDINARY_REQUEST_TIMEOUT_SECS,
350        )?;
351        validate_demo_value(
352            "cleanup_interval_secs",
353            self.cleanup_interval_secs,
354            DEMO_CLEANUP_INTERVAL_SECS,
355        )?;
356        Ok(())
357    }
358}
359
360fn validate_demo_value<T>(field: &str, actual: T, expected: T) -> Result<(), String>
361where
362    T: PartialEq + std::fmt::Display,
363{
364    if actual == expected {
365        Ok(())
366    } else {
367        Err(format!("demo.{field} must be exactly {expected}"))
368    }
369}
370
371/// Optional declarations of the fixed [`DemoPolicy`] contract.
372///
373/// Missing keys receive the approved value. A present key must state that same value; public
374/// Demo deployments cannot weaken or silently diverge from the documented resource policy.
375#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
376#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
377#[serde(deny_unknown_fields)]
378pub struct DemoPolicyConfig {
379    /// Anonymous visitor-session lifetime. Fixed at 86,400 seconds.
380    #[cfg_attr(feature = "schemars", schemars(range(min = 86_400, max = 86_400)))]
381    pub session_ttl_secs: Option<u64>,
382    /// Simulator polling interval. Fixed at 200 milliseconds.
383    #[cfg_attr(feature = "schemars", schemars(range(min = 200, max = 200)))]
384    pub poll_interval_ms: Option<u64>,
385    /// Whole-run timeout. Fixed at 30 seconds.
386    #[cfg_attr(feature = "schemars", schemars(range(min = 30, max = 30)))]
387    pub run_timeout_secs: Option<u64>,
388    /// Active Demo tune limit across all visitors. Fixed at 8.
389    #[cfg_attr(feature = "schemars", schemars(range(min = 8, max = 8)))]
390    pub max_active_runs_global: Option<u32>,
391    /// Active Demo tune limit for one visitor. Fixed at 1.
392    #[cfg_attr(feature = "schemars", schemars(range(min = 1, max = 1)))]
393    pub max_active_runs_per_visitor: Option<u32>,
394    /// Accepted starts for one session token in the quota window. Fixed at 6.
395    #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
396    pub accepted_starts_per_token: Option<u32>,
397    /// Accepted starts for one client IP in the quota window. Fixed at 6.
398    #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
399    pub accepted_starts_per_client_ip: Option<u32>,
400    /// Window shared by both accepted-start quotas. Fixed at 600 seconds.
401    #[cfg_attr(feature = "schemars", schemars(range(min = 600, max = 600)))]
402    pub accepted_start_window_secs: Option<u64>,
403    /// Completed runs retained for one visitor. Fixed at 10.
404    #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
405    pub retained_runs_per_visitor: Option<u32>,
406    /// Runs retained for one session. Fixed at 10.
407    #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
408    pub max_runs_per_session: Option<u32>,
409    /// Current Demo-owned `tune_runs` row limit across all visitors. Fixed at 5,000.
410    #[cfg_attr(feature = "schemars", schemars(range(min = 5_000, max = 5_000)))]
411    pub max_tune_run_rows_global: Option<u32>,
412    /// Maximum JSON request-body size. Fixed at 32,768 bytes.
413    #[cfg_attr(feature = "schemars", schemars(range(min = 32_768, max = 32_768)))]
414    pub max_json_body_bytes: Option<u64>,
415    /// Simultaneous SSE streams for one visitor. Fixed at 2.
416    #[cfg_attr(feature = "schemars", schemars(range(min = 2, max = 2)))]
417    pub max_sse_per_visitor: Option<u32>,
418    /// Simultaneous Demo SSE streams across all visitors. Fixed at 32.
419    #[cfg_attr(feature = "schemars", schemars(range(min = 32, max = 32)))]
420    pub max_sse_global: Option<u32>,
421    /// Absolute lifetime of one Demo SSE stream. Fixed at 45 seconds.
422    #[cfg_attr(feature = "schemars", schemars(range(min = 45, max = 45)))]
423    pub sse_lifetime_secs: Option<u64>,
424    /// Concurrent ordinary, non-streaming Demo API requests. Fixed at 64.
425    #[cfg_attr(feature = "schemars", schemars(range(min = 64, max = 64)))]
426    pub ordinary_request_concurrency: Option<u32>,
427    /// Timeout for an ordinary, non-streaming Demo API request. Fixed at 10 seconds.
428    #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
429    pub ordinary_request_timeout_secs: Option<u64>,
430    /// Interval between Demo cleanup passes. Fixed at 300 seconds.
431    #[cfg_attr(feature = "schemars", schemars(range(min = 300, max = 300)))]
432    pub cleanup_interval_secs: Option<u64>,
433}
434
435pub fn resolve_demo_policy(config: &DemoPolicyConfig) -> Result<DemoPolicy, String> {
436    let defaults = DemoPolicy::default();
437    let policy = DemoPolicy {
438        session_ttl_secs: config.session_ttl_secs.unwrap_or(defaults.session_ttl_secs),
439        poll_interval_ms: config.poll_interval_ms.unwrap_or(defaults.poll_interval_ms),
440        run_timeout_secs: config.run_timeout_secs.unwrap_or(defaults.run_timeout_secs),
441        max_active_runs_global: config
442            .max_active_runs_global
443            .unwrap_or(defaults.max_active_runs_global),
444        max_active_runs_per_visitor: config
445            .max_active_runs_per_visitor
446            .unwrap_or(defaults.max_active_runs_per_visitor),
447        accepted_starts_per_token: config
448            .accepted_starts_per_token
449            .unwrap_or(defaults.accepted_starts_per_token),
450        accepted_starts_per_client_ip: config
451            .accepted_starts_per_client_ip
452            .unwrap_or(defaults.accepted_starts_per_client_ip),
453        accepted_start_window_secs: config
454            .accepted_start_window_secs
455            .unwrap_or(defaults.accepted_start_window_secs),
456        retained_runs_per_visitor: config
457            .retained_runs_per_visitor
458            .unwrap_or(defaults.retained_runs_per_visitor),
459        max_runs_per_session: config
460            .max_runs_per_session
461            .unwrap_or(defaults.max_runs_per_session),
462        max_tune_run_rows_global: config
463            .max_tune_run_rows_global
464            .unwrap_or(defaults.max_tune_run_rows_global),
465        max_json_body_bytes: config
466            .max_json_body_bytes
467            .unwrap_or(defaults.max_json_body_bytes),
468        max_sse_per_visitor: config
469            .max_sse_per_visitor
470            .unwrap_or(defaults.max_sse_per_visitor),
471        max_sse_global: config.max_sse_global.unwrap_or(defaults.max_sse_global),
472        sse_lifetime_secs: config
473            .sse_lifetime_secs
474            .unwrap_or(defaults.sse_lifetime_secs),
475        ordinary_request_concurrency: config
476            .ordinary_request_concurrency
477            .unwrap_or(defaults.ordinary_request_concurrency),
478        ordinary_request_timeout_secs: config
479            .ordinary_request_timeout_secs
480            .unwrap_or(defaults.ordinary_request_timeout_secs),
481        cleanup_interval_secs: config
482            .cleanup_interval_secs
483            .unwrap_or(defaults.cleanup_interval_secs),
484    };
485    policy.validate()?;
486    Ok(policy)
487}
488
489pub fn resolve_server_mode(
490    env_mode: Option<&str>,
491    config: &BhtuneConfig,
492) -> Result<ServerMode, String> {
493    let raw = env_mode.map(str::to_owned).or_else(|| {
494        config.server_mode.map(|mode| match mode {
495            ServerMode::Full => "full".to_owned(),
496            ServerMode::Demo => "demo".to_owned(),
497        })
498    });
499    match raw
500        .as_deref()
501        .unwrap_or("full")
502        .to_ascii_lowercase()
503        .as_str()
504    {
505        "full" => Ok(ServerMode::Full),
506        "demo" => Ok(ServerMode::Demo),
507        other => Err(format!(
508            "invalid server mode '{other}'; expected 'full' or 'demo'"
509        )),
510    }
511}
512
513pub fn resolve_demo_policy_from_config(config: &BhtuneConfig) -> Result<DemoPolicy, String> {
514    resolve_demo_policy(&config.demo)
515}
516
517pub fn resolve_origin(
518    env_origin: Option<String>,
519    config: &BhtuneConfig,
520    bind_addr: &str,
521    mode: ServerMode,
522) -> Result<Option<String>, String> {
523    let configured_origin = env_origin.or_else(|| config.origin.clone());
524    if mode == ServerMode::Demo {
525        let origin = configured_origin.unwrap_or_else(|| format!("http://{bind_addr}"));
526        validate_demo_origin(&origin)?;
527        return Ok(Some(origin));
528    }
529    Ok(configured_origin)
530}
531
532/// Validates the exact browser origin used by a public Demo deployment.
533///
534/// HTTPS is mandatory except for an explicit loopback HTTP origin used by local tests and
535/// development. Origins must contain only a scheme and authority: paths, query strings,
536/// fragments, credentials, and trailing slashes are rejected.
537pub fn validate_demo_origin(origin: &str) -> Result<(), String> {
538    if origin.trim() != origin || origin.chars().any(char::is_whitespace) {
539        return Err("demo origin must not contain whitespace".to_owned());
540    }
541    let (scheme, authority) = origin
542        .split_once("://")
543        .ok_or_else(|| "demo origin must be an absolute HTTPS origin".to_owned())?;
544    if authority.is_empty()
545        || authority.contains(['/', '?', '#', '@'])
546        || !valid_origin_authority(authority)
547    {
548        return Err(
549            "demo origin must contain only a host and optional port, with no path or credentials"
550                .to_owned(),
551        );
552    }
553    match scheme.to_ascii_lowercase().as_str() {
554        "https" => Ok(()),
555        "http" if authority_host(authority).is_some_and(is_loopback_host) => Ok(()),
556        "http" => Err(
557            "demo origin must use HTTPS; HTTP is allowed only for an explicit loopback origin"
558                .to_owned(),
559        ),
560        _ => Err("demo origin must use HTTPS".to_owned()),
561    }
562}
563
564fn valid_origin_authority(authority: &str) -> bool {
565    let Some((host, port)) = split_authority(authority) else {
566        return false;
567    };
568    !host.is_empty() && port.is_none_or(|port| !port.is_empty() && port.parse::<u16>().is_ok())
569}
570
571fn authority_host(authority: &str) -> Option<&str> {
572    split_authority(authority).map(|(host, _)| host)
573}
574
575fn split_authority(authority: &str) -> Option<(&str, Option<&str>)> {
576    if let Some(rest) = authority.strip_prefix('[') {
577        let closing = rest.find(']')?;
578        let host = &rest[..closing];
579        let suffix = &rest[closing + 1..];
580        return match suffix {
581            "" => Some((host, None)),
582            _ => suffix.strip_prefix(':').map(|port| (host, Some(port))),
583        };
584    }
585    match authority.rsplit_once(':') {
586        Some((host, port)) if !host.contains(':') => Some((host, Some(port))),
587        Some(_) => None,
588        None => Some((authority, None)),
589    }
590}
591
592fn is_loopback_host(host: &str) -> bool {
593    host.eq_ignore_ascii_case("localhost")
594        || host
595            .parse::<IpAddr>()
596            .is_ok_and(|address| address.is_loopback())
597}
598
599/// Validates a trusted reverse-proxy address before Demo requests may use forwarded client IPs.
600///
601/// The request path supports an exact IPv4/IPv6 address or a matching-family CIDR.
602pub fn validate_demo_trusted_proxy(trusted_proxy: Option<&str>) -> Result<(), String> {
603    let Some(value) = trusted_proxy else {
604        return Ok(());
605    };
606    if value.trim() != value || value.is_empty() {
607        return Err("demo trusted_proxy must be a non-empty IP address or CIDR".to_owned());
608    }
609    if value.parse::<IpAddr>().is_ok() {
610        return Ok(());
611    }
612    let (network, prefix) = value
613        .split_once('/')
614        .ok_or_else(|| "demo trusted_proxy must be an IP address or CIDR".to_owned())?;
615    let network = network
616        .parse::<IpAddr>()
617        .map_err(|_| "demo trusted_proxy CIDR must use a valid IP address".to_owned())?;
618    let prefix = prefix
619        .parse::<u32>()
620        .map_err(|_| "demo trusted_proxy CIDR prefix is invalid".to_owned())?;
621    let maximum = match network {
622        IpAddr::V4(_) => 32,
623        IpAddr::V6(_) => 128,
624    };
625    if prefix > maximum {
626        return Err(format!(
627            "demo trusted_proxy CIDR prefix must be between 0 and {maximum}"
628        ));
629    }
630    Ok(())
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    #[test]
637    fn demo_policy_defaults_are_valid_and_safe() {
638        let policy = resolve_demo_policy(&DemoPolicyConfig::default()).unwrap();
639        assert_eq!(policy, DemoPolicy::default());
640        assert!(policy.validate().is_ok());
641        assert_eq!(policy.session_ttl_secs, DEMO_SESSION_TTL_SECS);
642        assert_eq!(policy.poll_interval_ms, DEMO_POLL_INTERVAL_MS);
643        assert_eq!(policy.run_timeout_secs, DEMO_RUN_TIMEOUT_SECS);
644        assert_eq!(policy.max_active_runs_global, DEMO_MAX_ACTIVE_RUNS_GLOBAL);
645        assert_eq!(
646            policy.max_active_runs_per_visitor,
647            DEMO_MAX_ACTIVE_RUNS_PER_VISITOR
648        );
649        assert_eq!(
650            policy.accepted_starts_per_token,
651            DEMO_ACCEPTED_STARTS_PER_TOKEN
652        );
653        assert_eq!(
654            policy.accepted_starts_per_client_ip,
655            DEMO_ACCEPTED_STARTS_PER_CLIENT_IP
656        );
657        assert_eq!(
658            policy.accepted_start_window_secs,
659            DEMO_ACCEPTED_START_WINDOW_SECS
660        );
661        assert_eq!(
662            policy.retained_runs_per_visitor,
663            DEMO_RETAINED_RUNS_PER_VISITOR
664        );
665        assert_eq!(
666            policy.max_tune_run_rows_global,
667            DEMO_MAX_TUNE_RUN_ROWS_GLOBAL
668        );
669        assert_eq!(policy.max_json_body_bytes, DEMO_MAX_JSON_BODY_BYTES);
670        assert_eq!(policy.max_sse_per_visitor, DEMO_MAX_SSE_PER_VISITOR);
671        assert_eq!(policy.max_sse_global, DEMO_MAX_SSE_GLOBAL);
672        assert_eq!(policy.sse_lifetime_secs, DEMO_SSE_LIFETIME_SECS);
673        assert_eq!(
674            policy.ordinary_request_concurrency,
675            DEMO_ORDINARY_REQUEST_CONCURRENCY
676        );
677        assert_eq!(
678            policy.ordinary_request_timeout_secs,
679            DEMO_ORDINARY_REQUEST_TIMEOUT_SECS
680        );
681        assert_eq!(policy.cleanup_interval_secs, DEMO_CLEANUP_INTERVAL_SECS);
682    }
683
684    #[test]
685    fn demo_policy_accepts_explicit_contract_values() {
686        let explicit = DemoPolicyConfig {
687            session_ttl_secs: Some(DEMO_SESSION_TTL_SECS),
688            poll_interval_ms: Some(DEMO_POLL_INTERVAL_MS),
689            run_timeout_secs: Some(DEMO_RUN_TIMEOUT_SECS),
690            max_active_runs_global: Some(DEMO_MAX_ACTIVE_RUNS_GLOBAL),
691            max_active_runs_per_visitor: Some(DEMO_MAX_ACTIVE_RUNS_PER_VISITOR),
692            accepted_starts_per_token: Some(DEMO_ACCEPTED_STARTS_PER_TOKEN),
693            accepted_starts_per_client_ip: Some(DEMO_ACCEPTED_STARTS_PER_CLIENT_IP),
694            accepted_start_window_secs: Some(DEMO_ACCEPTED_START_WINDOW_SECS),
695            retained_runs_per_visitor: Some(DEMO_RETAINED_RUNS_PER_VISITOR),
696            max_runs_per_session: Some(DEMO_MAX_RUNS_PER_SESSION),
697            max_tune_run_rows_global: Some(DEMO_MAX_TUNE_RUN_ROWS_GLOBAL),
698            max_json_body_bytes: Some(DEMO_MAX_JSON_BODY_BYTES),
699            max_sse_per_visitor: Some(DEMO_MAX_SSE_PER_VISITOR),
700            max_sse_global: Some(DEMO_MAX_SSE_GLOBAL),
701            sse_lifetime_secs: Some(DEMO_SSE_LIFETIME_SECS),
702            ordinary_request_concurrency: Some(DEMO_ORDINARY_REQUEST_CONCURRENCY),
703            ordinary_request_timeout_secs: Some(DEMO_ORDINARY_REQUEST_TIMEOUT_SECS),
704            cleanup_interval_secs: Some(DEMO_CLEANUP_INTERVAL_SECS),
705        };
706        assert_eq!(
707            resolve_demo_policy(&explicit).unwrap(),
708            DemoPolicy::default()
709        );
710    }
711
712    #[test]
713    fn demo_policy_rejects_every_contract_override() {
714        macro_rules! assert_invalid {
715            ($field:ident, $value:expr) => {
716                assert!(
717                    DemoPolicy {
718                        $field: $value,
719                        ..DemoPolicy::default()
720                    }
721                    .validate()
722                    .unwrap_err()
723                    .contains(concat!("demo.", stringify!($field))),
724                    "{} unexpectedly accepted an override",
725                    stringify!($field)
726                );
727            };
728        }
729
730        assert_invalid!(session_ttl_secs, DEMO_SESSION_TTL_SECS - 1);
731        assert_invalid!(poll_interval_ms, DEMO_POLL_INTERVAL_MS + 1);
732        assert_invalid!(run_timeout_secs, DEMO_RUN_TIMEOUT_SECS + 1);
733        assert_invalid!(max_active_runs_global, DEMO_MAX_ACTIVE_RUNS_GLOBAL + 1);
734        assert_invalid!(
735            max_active_runs_per_visitor,
736            DEMO_MAX_ACTIVE_RUNS_PER_VISITOR + 1
737        );
738        assert_invalid!(
739            accepted_starts_per_token,
740            DEMO_ACCEPTED_STARTS_PER_TOKEN + 1
741        );
742        assert_invalid!(
743            accepted_starts_per_client_ip,
744            DEMO_ACCEPTED_STARTS_PER_CLIENT_IP + 1
745        );
746        assert_invalid!(
747            accepted_start_window_secs,
748            DEMO_ACCEPTED_START_WINDOW_SECS + 1
749        );
750        assert_invalid!(
751            retained_runs_per_visitor,
752            DEMO_RETAINED_RUNS_PER_VISITOR + 1
753        );
754        assert_invalid!(max_runs_per_session, DEMO_MAX_RUNS_PER_SESSION + 1);
755        assert_invalid!(max_tune_run_rows_global, DEMO_MAX_TUNE_RUN_ROWS_GLOBAL + 1);
756        assert_invalid!(max_json_body_bytes, DEMO_MAX_JSON_BODY_BYTES + 1);
757        assert_invalid!(max_sse_per_visitor, DEMO_MAX_SSE_PER_VISITOR + 1);
758        assert_invalid!(max_sse_global, DEMO_MAX_SSE_GLOBAL + 1);
759        assert_invalid!(sse_lifetime_secs, DEMO_SSE_LIFETIME_SECS + 1);
760        assert_invalid!(
761            ordinary_request_concurrency,
762            DEMO_ORDINARY_REQUEST_CONCURRENCY + 1
763        );
764        assert_invalid!(
765            ordinary_request_timeout_secs,
766            DEMO_ORDINARY_REQUEST_TIMEOUT_SECS + 1
767        );
768        assert_invalid!(cleanup_interval_secs, DEMO_CLEANUP_INTERVAL_SECS + 1);
769    }
770
771    #[test]
772    fn demo_policy_config_rejects_an_override_during_resolution() {
773        let error = resolve_demo_policy(&DemoPolicyConfig {
774            accepted_starts_per_token: Some(DEMO_ACCEPTED_STARTS_PER_TOKEN + 1),
775            ..Default::default()
776        })
777        .unwrap_err();
778        assert_eq!(
779            error,
780            format!(
781                "demo.accepted_starts_per_token must be exactly \
782                 {DEMO_ACCEPTED_STARTS_PER_TOKEN}"
783            )
784        );
785    }
786
787    #[cfg(feature = "schemars")]
788    #[test]
789    fn demo_policy_schema_documents_exact_values_and_distinct_limits() {
790        let schema = serde_json::to_value(schemars::schema_for!(DemoPolicyConfig)).unwrap();
791        let properties = schema["properties"].as_object().unwrap();
792        let expected = [
793            ("session_ttl_secs", 86_400_u64),
794            ("poll_interval_ms", 200),
795            ("run_timeout_secs", 30),
796            ("max_active_runs_global", 8),
797            ("max_active_runs_per_visitor", 1),
798            ("accepted_starts_per_token", 6),
799            ("accepted_starts_per_client_ip", 6),
800            ("accepted_start_window_secs", 600),
801            ("retained_runs_per_visitor", 10),
802            ("max_runs_per_session", 10),
803            ("max_tune_run_rows_global", 5_000),
804            ("max_json_body_bytes", 32_768),
805            ("max_sse_per_visitor", 2),
806            ("max_sse_global", 32),
807            ("sse_lifetime_secs", 45),
808            ("ordinary_request_concurrency", 64),
809            ("ordinary_request_timeout_secs", 10),
810            ("cleanup_interval_secs", 300),
811        ];
812        assert_eq!(properties.len(), expected.len());
813        for (name, value) in expected {
814            let property = &properties[name];
815            assert_eq!(property["minimum"], value);
816            assert_eq!(property["maximum"], value);
817            assert!(
818                property["description"]
819                    .as_str()
820                    .is_some_and(|description| !description.is_empty())
821            );
822        }
823        assert_eq!(schema["additionalProperties"], false);
824        assert!(!properties.contains_key("token_requests_per_window"));
825        assert!(!properties.contains_key("request_timeout_secs"));
826    }
827
828    #[test]
829    fn server_mode_resolution_prefers_environment_then_config() {
830        let config = BhtuneConfig {
831            server_mode: Some(ServerMode::Demo),
832            ..Default::default()
833        };
834        assert_eq!(
835            resolve_server_mode(Some("full"), &config).unwrap(),
836            ServerMode::Full
837        );
838        assert_eq!(
839            resolve_server_mode(None, &config).unwrap(),
840            ServerMode::Demo
841        );
842        let full_config = BhtuneConfig {
843            server_mode: Some(ServerMode::Full),
844            ..Default::default()
845        };
846        assert_eq!(
847            resolve_server_mode(None, &full_config).unwrap(),
848            ServerMode::Full
849        );
850        assert!(resolve_server_mode(Some("invalid"), &config).is_err());
851    }
852
853    #[test]
854    fn obsolete_mode_key_no_longer_selects_demo_mode() {
855        let config: BhtuneConfig = toml::from_str("mode = \"demo\"").unwrap();
856        assert_eq!(config.server_mode, None);
857        assert_eq!(
858            resolve_server_mode(None, &config).unwrap(),
859            ServerMode::Full
860        );
861    }
862
863    #[test]
864    fn demo_origin_requires_https_except_for_explicit_loopback_http() {
865        for valid in [
866            "https://demo.example",
867            "https://demo.example:8443",
868            "http://localhost:8787",
869            "http://127.0.0.1:8787",
870            "http://127.0.0.1:0",
871            "http://[::1]:8787",
872        ] {
873            assert!(validate_demo_origin(valid).is_ok(), "{valid}");
874        }
875        for invalid in [
876            "http://demo.example",
877            "http://0.0.0.0:8787",
878            "https://demo.example/",
879            "https://user@demo.example",
880            "https://demo.example/path",
881            "https://demo.example?query",
882            "https://demo.example#fragment",
883            "ftp://demo.example",
884            "demo.example",
885            " https://demo.example",
886            "https://demo.example:65536",
887            "https://demo.example:not-a-port",
888            "https://[::1",
889            "https://::1",
890        ] {
891            assert!(validate_demo_origin(invalid).is_err(), "{invalid}");
892        }
893    }
894
895    #[test]
896    fn origin_resolution_keeps_full_mode_automatic_and_validates_demo() {
897        let config = BhtuneConfig {
898            origin: Some("https://config.example".to_owned()),
899            ..Default::default()
900        };
901        assert_eq!(
902            resolve_origin(
903                Some("https://environment.example".to_owned()),
904                &config,
905                "127.0.0.1:8787",
906                ServerMode::Demo,
907            )
908            .unwrap(),
909            Some("https://environment.example".to_owned())
910        );
911        assert_eq!(
912            resolve_origin(None, &config, "127.0.0.1:8787", ServerMode::Demo).unwrap(),
913            Some("https://config.example".to_owned())
914        );
915        assert_eq!(
916            resolve_origin(
917                None,
918                &BhtuneConfig::default(),
919                "127.0.0.1:8787",
920                ServerMode::Demo,
921            )
922            .unwrap(),
923            Some("http://127.0.0.1:8787".to_owned())
924        );
925        assert_eq!(
926            resolve_origin(
927                None,
928                &BhtuneConfig::default(),
929                "0.0.0.0:8787",
930                ServerMode::Full,
931            )
932            .unwrap(),
933            None
934        );
935        assert_eq!(
936            resolve_origin(
937                Some("https://configured.example".to_owned()),
938                &BhtuneConfig::default(),
939                "0.0.0.0:8787",
940                ServerMode::Full,
941            )
942            .unwrap(),
943            Some("https://configured.example".to_owned())
944        );
945        assert!(
946            resolve_origin(
947                None,
948                &BhtuneConfig::default(),
949                "0.0.0.0:8787",
950                ServerMode::Demo,
951            )
952            .is_err()
953        );
954    }
955
956    #[test]
957    fn demo_trusted_proxy_accepts_only_supported_exact_addresses_and_networks() {
958        for valid in [
959            None,
960            Some("127.0.0.1"),
961            Some("::1"),
962            Some("10.0.0.0/24"),
963            Some("10.0.0.1/24"),
964            Some("0.0.0.0/0"),
965            Some("192.0.2.4/32"),
966            Some("2001:db8::/32"),
967        ] {
968            assert!(validate_demo_trusted_proxy(valid).is_ok(), "{valid:?}");
969        }
970        for invalid in [
971            Some(""),
972            Some(" 10.0.0.1"),
973            Some("proxy.example"),
974            Some("10.0.0.0/33"),
975            Some("::1/129"),
976        ] {
977            assert!(validate_demo_trusted_proxy(invalid).is_err(), "{invalid:?}");
978        }
979    }
980}