1#![allow(rustdoc::broken_intra_doc_links)]
2
3use serde::{Deserialize, Serialize};
4use std::net::IpAddr;
5
6use super::model::BhtuneConfig;
7
8pub const DEMO_SESSION_TTL_SECS: u64 = 86_400;
10
11pub const DEMO_POLL_INTERVAL_MS: u64 = 200;
13
14pub const DEMO_RUN_TIMEOUT_SECS: u64 = 30;
16
17pub const DEMO_MAX_ACTIVE_RUNS_GLOBAL: u32 = 8;
19
20pub const DEMO_MAX_ACTIVE_RUNS_PER_VISITOR: u32 = 1;
22
23pub const DEMO_ACCEPTED_STARTS_PER_TOKEN: u32 = 6;
25
26pub const DEMO_ACCEPTED_STARTS_PER_CLIENT_IP: u32 = 6;
28
29pub const DEMO_ACCEPTED_START_WINDOW_SECS: u64 = 600;
31
32pub const DEMO_MAX_RUNS_PER_SESSION: u32 = 10;
34
35pub const DEMO_RETAINED_RUNS_PER_VISITOR: u32 = 10;
37
38pub const DEMO_MAX_TUNE_RUN_ROWS_GLOBAL: u32 = 5_000;
40
41pub const DEMO_MAX_JSON_BODY_BYTES: u64 = 32_768;
43
44pub const DEMO_MAX_SSE_PER_VISITOR: u32 = 2;
46
47pub const DEMO_MAX_SSE_GLOBAL: u32 = 32;
49
50pub const DEMO_SSE_LIFETIME_SECS: u64 = 45;
52
53pub const DEMO_ORDINARY_REQUEST_CONCURRENCY: u32 = 64;
55
56pub const DEMO_ORDINARY_REQUEST_TIMEOUT_SECS: u64 = 10;
58
59pub const DEMO_CLEANUP_INTERVAL_SECS: u64 = 300;
61
62pub const DEMO_TEMPLATE_NAME: &str = "Yokogawa CentumVP";
64
65pub const DEMO_TAG_NAME: &str = "Simulator demo";
70
71pub const DEMO_RANGE_LOW: f32 = 0.0;
73
74pub const DEMO_RANGE_HIGH: f32 = 100.0;
76
77pub const DEMO_RANGE_ENDPOINT_MIN: f32 = -1_000.0;
79
80pub const DEMO_RANGE_ENDPOINT_MAX: f32 = 1_000.0;
82
83pub const DEMO_RANGE_SPAN_MIN: f32 = 1.0;
85
86pub const DEMO_RANGE_SPAN_MAX: f32 = 1_000.0;
88
89pub const DEMO_SIM_GAIN_MIN: f32 = 0.1;
91
92pub const DEMO_SIM_GAIN_MAX: f32 = 5.0;
94
95pub const DEMO_SIM_TAU_MIN: f32 = 0.05;
97
98pub const DEMO_SIM_TAU_MAX: f32 = 5.0;
100
101pub const DEMO_SIM_DEAD_TIME_MIN: f32 = 0.0;
103
104pub const DEMO_SIM_DEAD_TIME_MAX: f32 = 2.0;
106
107pub const DEMO_SIM_NOISE_MIN: f32 = 0.0;
109
110pub const DEMO_SIM_NOISE_MAX_PV_SPAN_FRACTION: f32 = 0.05;
112
113pub const DEMO_SIM_SEED_MAX: u64 = i32::MAX as u64;
115
116pub const DEMO_RELAY_AMP_MIN: f32 = 1.0;
118
119pub const DEMO_RELAY_AMP_MAX: f32 = 20.0;
121
122pub const DEMO_RELAY_AMP_DEFAULT: f32 = 10.0;
124
125pub const DEMO_CYCLES_SKIP_MIN: u32 = 0;
127
128pub const DEMO_CYCLES_SKIP_MAX: u32 = 2;
130
131pub const DEMO_CYCLES_SKIP_DEFAULT: u32 = 1;
133
134pub const DEMO_CYCLES_COUNT_MIN: u32 = 1;
136
137pub const DEMO_CYCLES_COUNT_MAX: u32 = 3;
139
140pub const DEMO_CYCLES_COUNT_DEFAULT: u32 = 2;
142
143pub const DEMO_NOISE_PROTECTION_SECS_MIN: u32 = 0;
145
146pub const DEMO_NOISE_PROTECTION_SECS_MAX: u32 = 3;
148
149pub const DEMO_NOISE_PROTECTION_SECS_DEFAULT: u32 = 0;
151
152pub const DEMO_SIM_GAIN_DEFAULT: f32 = 1.0;
154
155pub const DEMO_SIM_TAU_DEFAULT: f32 = 0.5;
157
158pub const DEMO_SIM_DEAD_TIME_DEFAULT: f32 = 1.0;
160
161pub const DEMO_SIM_NOISE_DEFAULT: f32 = 0.0;
163
164pub const DEMO_SIM_SEED_DEFAULT: u64 = 0;
166
167pub const DEMO_SIM_INITIAL_VALUE_DEFAULT: f32 = 50.0;
169
170pub const DEMO_COOKIE_NAME: &str = "__Host-bhtune_demo_session";
172
173#[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#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
186#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
187pub struct DemoPolicy {
188 #[cfg_attr(feature = "schemars", schemars(range(min = 86_400, max = 86_400)))]
190 pub session_ttl_secs: u64,
191 #[cfg_attr(feature = "schemars", schemars(range(min = 200, max = 200)))]
193 pub poll_interval_ms: u64,
194 #[cfg_attr(feature = "schemars", schemars(range(min = 30, max = 30)))]
196 pub run_timeout_secs: u64,
197 #[cfg_attr(feature = "schemars", schemars(range(min = 8, max = 8)))]
199 pub max_active_runs_global: u32,
200 #[cfg_attr(feature = "schemars", schemars(range(min = 1, max = 1)))]
202 pub max_active_runs_per_visitor: u32,
203 #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
205 pub accepted_starts_per_token: u32,
206 #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
208 pub accepted_starts_per_client_ip: u32,
209 #[cfg_attr(feature = "schemars", schemars(range(min = 600, max = 600)))]
211 pub accepted_start_window_secs: u64,
212 #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
214 pub retained_runs_per_visitor: u32,
215 pub max_runs_per_session: u32,
217 #[cfg_attr(feature = "schemars", schemars(range(min = 5_000, max = 5_000)))]
219 pub max_tune_run_rows_global: u32,
220 #[cfg_attr(feature = "schemars", schemars(range(min = 32_768, max = 32_768)))]
222 pub max_json_body_bytes: u64,
223 #[cfg_attr(feature = "schemars", schemars(range(min = 2, max = 2)))]
225 pub max_sse_per_visitor: u32,
226 #[cfg_attr(feature = "schemars", schemars(range(min = 32, max = 32)))]
228 pub max_sse_global: u32,
229 #[cfg_attr(feature = "schemars", schemars(range(min = 45, max = 45)))]
231 pub sse_lifetime_secs: u64,
232 #[cfg_attr(feature = "schemars", schemars(range(min = 64, max = 64)))]
234 pub ordinary_request_concurrency: u32,
235 #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
237 pub ordinary_request_timeout_secs: u64,
238 #[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#[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 #[cfg_attr(feature = "schemars", schemars(range(min = 86_400, max = 86_400)))]
381 pub session_ttl_secs: Option<u64>,
382 #[cfg_attr(feature = "schemars", schemars(range(min = 200, max = 200)))]
384 pub poll_interval_ms: Option<u64>,
385 #[cfg_attr(feature = "schemars", schemars(range(min = 30, max = 30)))]
387 pub run_timeout_secs: Option<u64>,
388 #[cfg_attr(feature = "schemars", schemars(range(min = 8, max = 8)))]
390 pub max_active_runs_global: Option<u32>,
391 #[cfg_attr(feature = "schemars", schemars(range(min = 1, max = 1)))]
393 pub max_active_runs_per_visitor: Option<u32>,
394 #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
396 pub accepted_starts_per_token: Option<u32>,
397 #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
399 pub accepted_starts_per_client_ip: Option<u32>,
400 #[cfg_attr(feature = "schemars", schemars(range(min = 600, max = 600)))]
402 pub accepted_start_window_secs: Option<u64>,
403 #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
405 pub retained_runs_per_visitor: Option<u32>,
406 #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
408 pub max_runs_per_session: Option<u32>,
409 #[cfg_attr(feature = "schemars", schemars(range(min = 5_000, max = 5_000)))]
411 pub max_tune_run_rows_global: Option<u32>,
412 #[cfg_attr(feature = "schemars", schemars(range(min = 32_768, max = 32_768)))]
414 pub max_json_body_bytes: Option<u64>,
415 #[cfg_attr(feature = "schemars", schemars(range(min = 2, max = 2)))]
417 pub max_sse_per_visitor: Option<u32>,
418 #[cfg_attr(feature = "schemars", schemars(range(min = 32, max = 32)))]
420 pub max_sse_global: Option<u32>,
421 #[cfg_attr(feature = "schemars", schemars(range(min = 45, max = 45)))]
423 pub sse_lifetime_secs: Option<u64>,
424 #[cfg_attr(feature = "schemars", schemars(range(min = 64, max = 64)))]
426 pub ordinary_request_concurrency: Option<u32>,
427 #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
429 pub ordinary_request_timeout_secs: Option<u64>,
430 #[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
532pub 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
599pub 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}