1#![deny(
13 clippy::unwrap_used,
14 clippy::expect_used,
15 clippy::indexing_slicing,
16 clippy::panic,
17 clippy::unreachable
18)]
19
20use std::{net::SocketAddr, path::PathBuf, str::FromStr, time::Duration};
21
22use anyhow::{anyhow, bail, Context, Result};
23use sqlx::postgres::{PgConnectOptions, PgSslMode};
24
25pub const LOCAL_ENV_FILE: &str = "secrets/.env";
27
28pub const SYSTEM_ENV_FILE: Option<&str> = option_env!("TEAMCRAFT_SYSTEM_CONFIG");
32
33pub fn load_local_env_file() -> std::result::Result<bool, dotenvy::Error> {
36 load_env_file(LOCAL_ENV_FILE)
37}
38
39fn load_env_file(path: &str) -> std::result::Result<bool, dotenvy::Error> {
40 match dotenvy::from_path(path) {
41 Ok(()) => Ok(true),
42 Err(e) if e.not_found() => Ok(false),
43 Err(e) => Err(e),
44 }
45}
46
47pub fn load_env_files() -> Vec<(String, dotenvy::Error)> {
50 let mut paths: Vec<String> = Vec::new();
51 if let Ok(explicit) = std::env::var("TEAMCRAFT_ENV_FILE") {
52 if !explicit.trim().is_empty() {
53 paths.push(explicit.trim().to_string());
54 }
55 }
56 paths.push(LOCAL_ENV_FILE.to_string());
57 if let Some(system) = SYSTEM_ENV_FILE.filter(|p| !p.is_empty()) {
58 paths.push(system.to_string());
59 }
60 paths
61 .into_iter()
62 .filter_map(|path| load_env_file(&path).err().map(|e| (path, e)))
63 .collect()
64}
65
66pub const KNOWN_VARIABLES: &[&str] = &[
70 "DATABASE_URL",
71 "MATTERMOST_ERROR_WEBHOOK",
72 "TEAMCRAFT_ANTHROPIC_API_KEY",
73 "TEAMCRAFT_APPROVAL_REAUTH",
74 "TEAMCRAFT_ASSISTANT_API_KEY",
75 "TEAMCRAFT_ASSISTANT_BASE_URL",
76 "TEAMCRAFT_ASSISTANT_INTERNAL_URLS",
77 "TEAMCRAFT_ASSISTANT_MAX_CONCURRENT",
78 "TEAMCRAFT_ASSISTANT_MAX_STEPS",
79 "TEAMCRAFT_ASSISTANT_MAX_TOKENS",
80 "TEAMCRAFT_ASSISTANT_MODEL",
81 "TEAMCRAFT_ASSISTANT_PROVIDER",
82 "TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT",
83 "TEAMCRAFT_ASSISTANT_RETENTION_DAYS",
84 "TEAMCRAFT_ASSISTANT_RUN_TIMEOUT",
85 "TEAMCRAFT_AUDIT_RETENTION_DAYS",
86 "TEAMCRAFT_BACKUP_PASSPHRASE",
87 "TEAMCRAFT_BIND_ADDR",
88 "TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD",
89 "TEAMCRAFT_BOOTSTRAP_ADMIN_USERNAME",
90 "TEAMCRAFT_CLAMD_ADDR",
91 "TEAMCRAFT_CLAMD_MAX_MB",
92 "TEAMCRAFT_COOKIE_SECURE",
93 "TEAMCRAFT_DATABASE_URL",
94 "TEAMCRAFT_DB_HOST",
95 "TEAMCRAFT_DB_MAX_CONNECTIONS",
96 "TEAMCRAFT_DB_NAME",
97 "TEAMCRAFT_DB_PASSWORD",
98 "TEAMCRAFT_DB_PORT",
99 "TEAMCRAFT_DB_SSLMODE",
100 "TEAMCRAFT_DB_USER",
101 "TEAMCRAFT_ENV_FILE",
102 "TEAMCRAFT_INSTANCE_NAME",
103 "TEAMCRAFT_LOCAL_AGENTS",
104 "TEAMCRAFT_LOCAL_LOGIN",
105 "TEAMCRAFT_LOG_FORMAT",
106 "TEAMCRAFT_MAIL_FROM",
107 "TEAMCRAFT_MATTERMOST_BOT_TOKEN",
108 "TEAMCRAFT_MATTERMOST_DIRECT",
109 "TEAMCRAFT_MATTERMOST_URL",
110 "TEAMCRAFT_MATTERMOST_WEBHOOK",
111 "TEAMCRAFT_MAX_UPLOAD_MB",
112 "TEAMCRAFT_METRICS_TOKEN",
113 "TEAMCRAFT_OIDC_ADMIN_GROUP",
114 "TEAMCRAFT_OIDC_CLIENT_ID",
115 "TEAMCRAFT_OIDC_CLIENT_SECRET",
116 "TEAMCRAFT_OIDC_DISPLAY_NAME",
117 "TEAMCRAFT_OIDC_EDITOR_GROUP",
118 "TEAMCRAFT_OIDC_GROUPS_CLAIM",
119 "TEAMCRAFT_OIDC_ISSUER_URL",
120 "TEAMCRAFT_OIDC_SCOPES",
121 "TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS",
122 "TEAMCRAFT_OIDC_USERNAME_CLAIM",
123 "TEAMCRAFT_OIDC_VIEWER_GROUP",
124 "TEAMCRAFT_PREVIEW_CACHE_MAX_MB",
125 "TEAMCRAFT_PREVIEW_MAX_MEMORY_MB",
126 "TEAMCRAFT_PREVIEW_MAX_MESH_MB",
127 "TEAMCRAFT_PREVIEW_MAX_RESULT_MB",
128 "TEAMCRAFT_PREVIEW_MAX_STEP_MB",
129 "TEAMCRAFT_PUBLIC_URL",
130 "TEAMCRAFT_REQUEST_DIGEST_HOURS",
131 "TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS",
132 "TEAMCRAFT_SERVER",
133 "TEAMCRAFT_SESSION_TTL_HOURS",
134 "TEAMCRAFT_SIGNIN_MAX_FAILURES",
135 "TEAMCRAFT_SMTP_URL",
136 "TEAMCRAFT_TOKEN",
137 "TEAMCRAFT_UPLOAD_CHUNK_MB",
138 "TEAMCRAFT_VAULT_DIR",
139 "TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS",
140 "TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE",
141 "TEAMCRAFT_WEBHOOK_RETENTION_DAYS",
142];
143
144pub const TOOLING_VARIABLES: &[&str] = &[
147 "TEAMCRAFT_REQUIRE_DB_TESTS",
148 "TEAMCRAFT_REQUIRE_PG_TOOLS",
149 "TEAMCRAFT_SYSTEM_CONFIG",
150 "TEAMCRAFT_TEST_DATABASE_URL",
151 "TEAMCRAFT_WRITE_SIM_FIXTURES",
152];
153
154fn edit_distance(a: &str, b: &str) -> usize {
156 let b: Vec<char> = b.chars().collect();
157 let mut row: Vec<usize> = (0..=b.len()).collect();
158 for (i, ca) in a.chars().enumerate() {
159 let mut diagonal = i;
160 if let Some(first) = row.first_mut() {
161 *first = i + 1;
162 }
163 for (j, cb) in b.iter().enumerate() {
164 let above = row.get(j + 1).copied().unwrap_or(0);
165 let left = row.get(j).copied().unwrap_or(0);
166 let value = (diagonal + usize::from(ca != *cb))
167 .min(above + 1)
168 .min(left + 1);
169 if let Some(slot) = row.get_mut(j + 1) {
170 *slot = value;
171 }
172 diagonal = above;
173 }
174 }
175 row.last().copied().unwrap_or(0)
176}
177
178fn nearest_known(base: &str) -> Option<&'static str> {
180 let limit = (base.len() / 6).clamp(2, 4);
181 KNOWN_VARIABLES
182 .iter()
183 .map(|known| (edit_distance(base, known), *known))
184 .filter(|(distance, _)| *distance <= limit)
185 .min_by_key(|(distance, _)| *distance)
186 .map(|(_, known)| known)
187}
188
189pub fn unknown_variables<I: IntoIterator<Item = String>>(
192 names: I,
193) -> Vec<(String, Option<String>)> {
194 let mut out: Vec<(String, Option<String>)> = names
195 .into_iter()
196 .filter(|n| n.starts_with("TEAMCRAFT_") || n.starts_with("MATTERMOST_"))
197 .filter_map(|name| {
198 let (base, file) = match name.strip_suffix("_FILE") {
199 Some(base) => (base, true),
200 None => (name.as_str(), false),
201 };
202 let is_known = |n: &str| KNOWN_VARIABLES.contains(&n) || TOOLING_VARIABLES.contains(&n);
203 if is_known(&name) || is_known(base) {
205 return None;
206 }
207 let hint = nearest_known(base).map(|known| {
208 if file {
209 format!("{known}_FILE")
210 } else {
211 known.to_string()
212 }
213 });
214 Some((name, hint))
215 })
216 .collect();
217 out.sort();
218 out.dedup();
219 out
220}
221
222pub fn warn_unknown_variables() {
226 let names = std::env::vars_os().map(|(k, _)| k.to_string_lossy().into_owned());
227 for (name, hint) in unknown_variables(names) {
228 match hint {
229 Some(known) => tracing::warn!(
230 "unknown environment variable {name} is ignored; did you mean {known}?"
231 ),
232 None => tracing::warn!("unknown environment variable {name} is ignored"),
233 }
234 }
235}
236
237#[derive(Clone, Copy, Debug, PartialEq, Eq)]
238pub enum LogFormat {
239 Text,
240 Json,
241}
242
243#[derive(Clone)]
244pub struct Config {
245 pub bind_addr: SocketAddr,
247 pub public_url: String,
249 pub instance_name: String,
251 pub database: PgConnectOptions,
253 pub database_password: Option<String>,
257 pub database_display: String,
259 pub db_max_connections: u32,
260 pub vault_dir: PathBuf,
262 pub max_upload_bytes: u64,
263 pub preview: crate::preview::PreviewLimits,
265 pub preview_cache_max_bytes: u64,
269 pub upload_chunk_bytes: u64,
271 pub clamd: Option<crate::malware::ScannerConfig>,
274 pub backup_passphrase: Option<String>,
277 pub session_ttl: Duration,
278 pub approval_reauth: crate::domain::ApprovalReauth,
281 pub audit_retention_days: u32,
285 pub retention_latest_grace_days: u32,
290 pub signin_max_failures: u32,
294 pub oidc_token_idle_days: u32,
300 pub metrics_token: Option<String>,
302 pub cookie_secure: bool,
304 pub log_format: LogFormat,
305 pub bootstrap_admin: Option<BootstrapAdmin>,
307 pub oidc: Option<OidcConfig>,
309 pub local_login: bool,
311 pub mattermost_error_webhook: Option<String>,
313 pub smtp: Option<SmtpConfig>,
315 pub request_digest_hours: u64,
318 pub mattermost: Option<MattermostConfig>,
320 pub assistant: Option<AssistantConfig>,
322 pub local_agents: LocalAgents,
326 pub webhooks: WebhookConfig,
328}
329
330pub const MIN_AUDIT_RETENTION_DAYS: u32 = 30;
333
334const MAX_PREVIEW_CACHE_MB: u64 = 16 * 1024 * 1024;
336
337pub const DEFAULT_WEBHOOK_RETENTION_DAYS: u32 = 30;
339
340#[derive(Clone, Debug, PartialEq, Eq)]
343pub struct WebhookConfig {
344 pub allowed_hosts: Vec<String>,
348 pub allow_private: bool,
351 pub retention_days: u32,
353}
354
355impl Default for WebhookConfig {
356 fn default() -> Self {
357 WebhookConfig {
358 allowed_hosts: Vec::new(),
359 allow_private: false,
360 retention_days: DEFAULT_WEBHOOK_RETENTION_DAYS,
361 }
362 }
363}
364
365#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
378pub enum LocalAgents {
379 #[default]
381 Allowed,
382 Off,
385}
386
387pub const LOCAL_AGENT_HEADER: &str = "x-teamcraft-client";
390pub const LOCAL_AGENT_HEADER_VALUE: &str = "desktop-agent";
392
393impl LocalAgents {
394 pub fn parse(raw: &str) -> Result<LocalAgents> {
396 match raw.trim().to_ascii_lowercase().as_str() {
397 "allowed" => Ok(LocalAgents::Allowed),
398 "off" => Ok(LocalAgents::Off),
399 other => bail!("TEAMCRAFT_LOCAL_AGENTS must be 'allowed' or 'off', got '{other}'"),
400 }
401 }
402
403 pub fn as_str(self) -> &'static str {
404 match self {
405 LocalAgents::Allowed => "allowed",
406 LocalAgents::Off => "off",
407 }
408 }
409
410 pub fn allowed(self) -> bool {
411 self == LocalAgents::Allowed
412 }
413}
414
415fn parse_allowed_hosts(raw: &str) -> Result<Vec<String>> {
417 let mut hosts = Vec::new();
418 for entry in raw.split([',', ' ', '\n', '\t']).map(str::trim) {
419 if entry.is_empty() {
420 continue;
421 }
422 let host = entry
423 .trim_start_matches('[')
424 .trim_end_matches(']')
425 .trim_end_matches('.')
426 .to_ascii_lowercase();
427 let name = host.strip_prefix("*.").unwrap_or(&host);
428 if name.is_empty()
429 || name.contains('*')
430 || !name
431 .chars()
432 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':'))
433 {
434 bail!(
435 "TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS: '{entry}' is not a host name, an IP address or *.domain"
436 );
437 }
438 let host = host
440 .parse::<std::net::IpAddr>()
441 .map(|ip| ip.to_string())
442 .unwrap_or(host);
443 if !hosts.contains(&host) {
444 hosts.push(host);
445 }
446 }
447 Ok(hosts)
448}
449
450pub const DEFAULT_ASSISTANT_MODEL: &str = "claude-opus-5-5";
452pub const DEFAULT_ASSISTANT_MAX_STEPS: u32 = 12;
454pub const DEFAULT_ASSISTANT_MAX_TOKENS: u32 = 16_000;
456pub const DEFAULT_ASSISTANT_REQUEST_TIMEOUT: u64 = 180;
458pub const DEFAULT_ASSISTANT_RUN_TIMEOUT: u64 = 240;
460pub const DEFAULT_ASSISTANT_MAX_CONCURRENT: usize = 4;
462pub const DEFAULT_ASSISTANT_RETENTION_DAYS: u32 = 30;
464
465#[derive(Clone, Copy, Debug, PartialEq, Eq)]
467pub enum AssistantProvider {
468 Anthropic,
470 OpenAi,
472 OpenAiCompatible,
475}
476
477impl AssistantProvider {
478 pub fn as_str(self) -> &'static str {
479 match self {
480 AssistantProvider::Anthropic => "anthropic",
481 AssistantProvider::OpenAi => "openai",
482 AssistantProvider::OpenAiCompatible => "openai-compatible",
483 }
484 }
485
486 pub fn parse(s: &str) -> Option<AssistantProvider> {
487 match s.trim().to_ascii_lowercase().as_str() {
488 "anthropic" => Some(AssistantProvider::Anthropic),
489 "openai" => Some(AssistantProvider::OpenAi),
490 "openai-compatible" => Some(AssistantProvider::OpenAiCompatible),
491 _ => None,
492 }
493 }
494
495 pub fn default_base_url(self) -> Option<&'static str> {
497 match self {
498 AssistantProvider::Anthropic => Some("https://api.anthropic.com/v1"),
499 AssistantProvider::OpenAi => Some("https://api.openai.com/v1"),
500 AssistantProvider::OpenAiCompatible => None,
501 }
502 }
503}
504
505#[derive(Clone)]
506pub struct AssistantConfig {
507 pub provider: AssistantProvider,
508 pub base_url: String,
510 pub api_key: Option<String>,
512 pub model: String,
514 pub max_steps: u32,
516 pub max_tokens: u32,
518 pub request_timeout: Duration,
520 pub run_timeout: Duration,
522 pub max_concurrent: usize,
524 pub retention_days: u32,
526 pub internal_urls: Vec<String>,
530}
531
532impl std::fmt::Debug for AssistantConfig {
533 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534 f.debug_struct("AssistantConfig")
535 .field("provider", &self.provider)
536 .field("base_url", &self.base_url)
537 .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
538 .field("model", &self.model)
539 .field("max_steps", &self.max_steps)
540 .field("max_tokens", &self.max_tokens)
541 .field("request_timeout", &self.request_timeout)
542 .field("run_timeout", &self.run_timeout)
543 .field("max_concurrent", &self.max_concurrent)
544 .field("retention_days", &self.retention_days)
545 .field("internal_urls", &self.internal_urls)
546 .finish()
547 }
548}
549
550impl AssistantConfig {
551 pub fn host(&self) -> String {
553 reqwest::Url::parse(&self.base_url)
554 .ok()
555 .and_then(|u| u.host_str().map(str::to_string))
556 .unwrap_or_default()
557 }
558
559 pub fn label(&self) -> String {
561 format!("{}:{}@{}", self.provider.as_str(), self.model, self.host())
562 }
563
564 pub fn is_internal(&self) -> bool {
566 self.internal_urls
567 .iter()
568 .any(|u| url_is_under(&self.base_url, u))
569 }
570
571 pub fn insecure_key_warning(&self) -> Option<String> {
574 self.api_key.as_ref()?;
575 let url = reqwest::Url::parse(&self.base_url).ok()?;
576 if url.scheme() != "http" {
577 return None;
578 }
579 let host = url.host_str()?;
580 let bare = host.trim_start_matches('[').trim_end_matches(']');
581 let loopback = host.eq_ignore_ascii_case("localhost")
582 || host.to_ascii_lowercase().ends_with(".localhost")
583 || bare
584 .parse::<std::net::IpAddr>()
585 .is_ok_and(|ip| ip.is_loopback());
586 (!loopback).then(|| {
587 format!(
588 "the assistant API key is sent over plain http to {host}: use https, or a host on this computer"
589 )
590 })
591 }
592}
593
594fn assistant_config<F: Fn(&str) -> Option<String>>(
597 env: &Env<F>,
598) -> Result<Option<AssistantConfig>> {
599 let read_key = |name: &str| -> Result<Option<String>> {
601 let key = env
602 .get(name)?
603 .map(|k| k.trim().to_string())
604 .filter(|k| !k.is_empty());
605 if let Some(k) = &key {
606 if k.chars().any(|c| c.is_whitespace() || c.is_control()) {
607 bail!("{name} must not contain spaces or line breaks");
608 }
609 }
610 Ok(key)
611 };
612 let primary = read_key("TEAMCRAFT_ASSISTANT_API_KEY")?;
613 let alias = read_key("TEAMCRAFT_ANTHROPIC_API_KEY")?;
614 let api_key = match (primary, alias) {
615 (Some(a), Some(b)) if a != b => bail!(
616 "TEAMCRAFT_ASSISTANT_API_KEY and TEAMCRAFT_ANTHROPIC_API_KEY are both set with \
617 different values: set only TEAMCRAFT_ASSISTANT_API_KEY"
618 ),
619 (a, b) => a.or(b),
620 };
621 let explicit = env
622 .get("TEAMCRAFT_ASSISTANT_PROVIDER")?
623 .map(|p| p.trim().to_string())
624 .filter(|p| !p.is_empty());
625 let provider = match &explicit {
626 Some(p) => AssistantProvider::parse(p).ok_or_else(|| {
627 anyhow!("TEAMCRAFT_ASSISTANT_PROVIDER must be anthropic, openai or openai-compatible")
628 })?,
629 None => AssistantProvider::Anthropic,
630 };
631 let base_url = env
632 .get("TEAMCRAFT_ASSISTANT_BASE_URL")?
633 .map(|u| u.trim().to_string())
634 .filter(|u| !u.is_empty());
635 let enabled = match provider {
638 AssistantProvider::OpenAiCompatible => api_key.is_some() || base_url.is_some(),
639 _ => api_key.is_some(),
640 };
641 if !enabled {
642 return Ok(None);
643 }
644 let base_url = match base_url.as_deref().or(provider.default_base_url()) {
645 Some(u) => u.to_string(),
646 None => bail!("TEAMCRAFT_ASSISTANT_BASE_URL is required for TEAMCRAFT_ASSISTANT_PROVIDER=openai-compatible"),
647 };
648 let parsed = reqwest::Url::parse(&base_url)
649 .map_err(|e| anyhow!("TEAMCRAFT_ASSISTANT_BASE_URL is not a valid URL: {e}"))?;
650 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
651 bail!("TEAMCRAFT_ASSISTANT_BASE_URL must be an http:// or https:// URL with a host");
652 }
653 if !parsed.username().is_empty() || parsed.password().is_some() {
654 bail!("TEAMCRAFT_ASSISTANT_BASE_URL must not contain a user name or password");
655 }
656 if parsed.query().is_some() || parsed.fragment().is_some() {
657 bail!("TEAMCRAFT_ASSISTANT_BASE_URL must not contain a query or fragment");
658 }
659 let base_url = base_url.trim_end_matches('/').to_string();
660
661 let model = env
662 .get("TEAMCRAFT_ASSISTANT_MODEL")?
663 .map(|m| m.trim().to_string())
664 .filter(|m| !m.is_empty());
665 let model = match (model, provider) {
666 (Some(m), _) => m,
667 (None, AssistantProvider::Anthropic) => DEFAULT_ASSISTANT_MODEL.to_string(),
668 (None, _) => bail!("TEAMCRAFT_ASSISTANT_MODEL is required for the openai providers"),
669 };
670 if !model
671 .chars()
672 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | ':' | '@' | '/'))
673 {
674 bail!("TEAMCRAFT_ASSISTANT_MODEL must be a model id such as {DEFAULT_ASSISTANT_MODEL}");
675 }
676 let max_steps = env.parse_or("TEAMCRAFT_ASSISTANT_MAX_STEPS", DEFAULT_ASSISTANT_MAX_STEPS)?;
677 if !(1..=50).contains(&max_steps) {
678 bail!("TEAMCRAFT_ASSISTANT_MAX_STEPS must be between 1 and 50");
679 }
680 let max_tokens = env.parse_or(
681 "TEAMCRAFT_ASSISTANT_MAX_TOKENS",
682 DEFAULT_ASSISTANT_MAX_TOKENS,
683 )?;
684 if !(256..=200_000).contains(&max_tokens) {
685 bail!("TEAMCRAFT_ASSISTANT_MAX_TOKENS must be between 256 and 200000");
686 }
687 let request_timeout = env.parse_or(
688 "TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT",
689 DEFAULT_ASSISTANT_REQUEST_TIMEOUT,
690 )?;
691 let run_timeout = env.parse_or(
692 "TEAMCRAFT_ASSISTANT_RUN_TIMEOUT",
693 DEFAULT_ASSISTANT_RUN_TIMEOUT.max(request_timeout),
694 )?;
695 if !(5..=3600).contains(&request_timeout) {
696 bail!("TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT must be between 5 and 3600 seconds");
697 }
698 if !(5..=7200).contains(&run_timeout) || run_timeout < request_timeout {
699 bail!(
700 "TEAMCRAFT_ASSISTANT_RUN_TIMEOUT must be between 5 and 7200 seconds and at least \
701 TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT"
702 );
703 }
704 let max_concurrent = env.parse_or(
705 "TEAMCRAFT_ASSISTANT_MAX_CONCURRENT",
706 DEFAULT_ASSISTANT_MAX_CONCURRENT,
707 )?;
708 if !(1..=64).contains(&max_concurrent) {
709 bail!("TEAMCRAFT_ASSISTANT_MAX_CONCURRENT must be between 1 and 64");
710 }
711 let retention_days = env.parse_or(
712 "TEAMCRAFT_ASSISTANT_RETENTION_DAYS",
713 DEFAULT_ASSISTANT_RETENTION_DAYS,
714 )?;
715 if !(1..=365).contains(&retention_days) {
716 bail!("TEAMCRAFT_ASSISTANT_RETENTION_DAYS must be between 1 and 365");
717 }
718 let internal_urls = internal_urls(env)?;
719 Ok(Some(AssistantConfig {
720 provider,
721 base_url,
722 api_key,
723 model,
724 max_steps,
725 max_tokens,
726 request_timeout: Duration::from_secs(request_timeout),
727 run_timeout: Duration::from_secs(run_timeout),
728 max_concurrent,
729 retention_days,
730 internal_urls,
731 }))
732}
733
734pub fn url_is_under(base_url: &str, listed: &str) -> bool {
737 let (Ok(a), Ok(b)) = (reqwest::Url::parse(base_url), reqwest::Url::parse(listed)) else {
738 return false;
739 };
740 if a.scheme() != b.scheme()
741 || a.host_str().map(str::to_ascii_lowercase) != b.host_str().map(str::to_ascii_lowercase)
742 || a.port_or_known_default() != b.port_or_known_default()
743 {
744 return false;
745 }
746 let (pa, pb) = (
747 a.path().trim_end_matches('/'),
748 b.path().trim_end_matches('/'),
749 );
750 pa == pb || pa.starts_with(&format!("{pb}/"))
751}
752
753fn internal_urls<F: Fn(&str) -> Option<String>>(env: &Env<F>) -> Result<Vec<String>> {
756 let raw = env
757 .get("TEAMCRAFT_ASSISTANT_INTERNAL_URLS")?
758 .unwrap_or_default();
759 let mut out = Vec::new();
760 for entry in raw
761 .split(|c: char| c == ',' || c.is_whitespace())
762 .filter(|e| !e.is_empty())
763 {
764 let url = reqwest::Url::parse(entry).map_err(|e| {
765 anyhow!("TEAMCRAFT_ASSISTANT_INTERNAL_URLS: '{entry}' is not a valid URL: {e}")
766 })?;
767 if !matches!(url.scheme(), "http" | "https")
768 || url.host_str().is_none()
769 || !url.username().is_empty()
770 || url.password().is_some()
771 || url.query().is_some()
772 || url.fragment().is_some()
773 {
774 bail!(
775 "TEAMCRAFT_ASSISTANT_INTERNAL_URLS: '{entry}' must be an http:// or https:// URL \
776 with a host and without credentials, query or fragment"
777 );
778 }
779 out.push(entry.trim_end_matches('/').to_string());
780 }
781 Ok(out)
782}
783
784#[derive(Clone)]
785pub struct SmtpConfig {
786 pub url: String,
789 pub from: String,
791}
792
793#[derive(Clone)]
794pub struct MattermostConfig {
795 pub bot: Option<MattermostBot>,
797 pub webhook: Option<String>,
799 pub direct: bool,
802}
803
804impl MattermostConfig {
805 pub fn is_direct(&self) -> bool {
807 self.bot.is_some() || self.direct
808 }
809
810 pub fn is_legacy_webhook(&self) -> bool {
812 self.bot.is_none() && self.webhook.is_some()
813 }
814}
815
816#[derive(Clone)]
817pub struct MattermostBot {
818 pub url: String,
820 pub token: String,
822}
823
824#[derive(Clone)]
825pub struct BootstrapAdmin {
826 pub username: String,
827 pub password: String,
828}
829
830#[derive(Clone)]
831pub struct OidcConfig {
832 pub issuer_url: String,
834 pub client_id: String,
835 pub client_secret: String,
836 pub scopes: String,
838 pub display_name: String,
840 pub groups_claim: String,
842 pub username_claim: String,
844 pub admin_group: String,
846 pub editor_group: String,
848 pub viewer_group: Option<String>,
852}
853
854impl OidcConfig {
855 pub fn redirect_uri(&self, public_url: &str) -> String {
857 format!("{public_url}/auth/oidc/callback")
858 }
859}
860
861impl Config {
862 pub fn from_env() -> Result<Self> {
864 Self::from_lookup(|key| std::env::var(key).ok())
865 }
866
867 pub fn from_lookup<F>(lookup: F) -> Result<Self>
869 where
870 F: Fn(&str) -> Option<String>,
871 {
872 let env = Env { lookup };
873
874 let bind_addr: SocketAddr = env
875 .get("TEAMCRAFT_BIND_ADDR")?
876 .unwrap_or_else(|| "127.0.0.1:8080".to_string())
877 .parse()
878 .context("TEAMCRAFT_BIND_ADDR must be a socket address such as 0.0.0.0:8080")?;
879
880 let public_url = env
881 .get("TEAMCRAFT_PUBLIC_URL")?
882 .unwrap_or_else(|| format!("http://localhost:{}", bind_addr.port()))
883 .trim_end_matches('/')
884 .to_string();
885 if !(public_url.starts_with("http://") || public_url.starts_with("https://")) {
886 bail!("TEAMCRAFT_PUBLIC_URL must start with http:// or https://");
887 }
888
889 let instance_name = env
890 .get("TEAMCRAFT_INSTANCE_NAME")?
891 .unwrap_or_else(|| "TeamCraft".to_string());
892
893 let (database, database_display, database_password) = database_options(&env)?;
894
895 let db_max_connections = env.parse_or("TEAMCRAFT_DB_MAX_CONNECTIONS", 10u32)?;
896 if db_max_connections == 0 {
897 bail!("TEAMCRAFT_DB_MAX_CONNECTIONS must be at least 1");
898 }
899
900 let vault_dir = PathBuf::from(
901 env.get("TEAMCRAFT_VAULT_DIR")?
902 .unwrap_or_else(|| "./data/vault".to_string()),
903 );
904 let max_upload_mb = env.parse_or("TEAMCRAFT_MAX_UPLOAD_MB", 512u64)?;
905 if max_upload_mb == 0 {
906 bail!("TEAMCRAFT_MAX_UPLOAD_MB must be at least 1");
907 }
908 let preview = {
909 let defaults = crate::preview::PreviewLimits::default();
910 let mb = |key: &str, default: u64, min: u64| -> Result<u64> {
911 let value = env.parse_or(key, default / (1024 * 1024))?;
912 if !(min..=1_048_576).contains(&value) {
913 bail!("{key} must be between {min} and 1048576");
914 }
915 Ok(value * 1024 * 1024)
916 };
917 crate::preview::PreviewLimits {
918 max_result_bytes: mb(
919 "TEAMCRAFT_PREVIEW_MAX_RESULT_MB",
920 defaults.max_result_bytes,
921 1,
922 )?,
923 max_mesh_bytes: mb("TEAMCRAFT_PREVIEW_MAX_MESH_MB", defaults.max_mesh_bytes, 1)?,
924 max_step_bytes: mb("TEAMCRAFT_PREVIEW_MAX_STEP_MB", defaults.max_step_bytes, 1)?,
925 max_memory_bytes: mb(
927 "TEAMCRAFT_PREVIEW_MAX_MEMORY_MB",
928 defaults.max_memory_bytes,
929 0,
930 )?,
931 }
932 };
933 let preview_cache_max_mb = env.parse_or("TEAMCRAFT_PREVIEW_CACHE_MAX_MB", 0u64)?;
935 if preview_cache_max_mb > MAX_PREVIEW_CACHE_MB {
936 bail!("TEAMCRAFT_PREVIEW_CACHE_MAX_MB must be between 0 (no limit) and {MAX_PREVIEW_CACHE_MB}");
937 }
938 let upload_chunk_mb = env.parse_or("TEAMCRAFT_UPLOAD_CHUNK_MB", 16u64)?;
939 if !(1..=1024).contains(&upload_chunk_mb) {
940 bail!("TEAMCRAFT_UPLOAD_CHUNK_MB must be between 1 and 1024");
941 }
942 let clamd_max_mb = env.parse_or("TEAMCRAFT_CLAMD_MAX_MB", 25u64)?;
943 if !(1..=4096).contains(&clamd_max_mb) {
944 bail!("TEAMCRAFT_CLAMD_MAX_MB must be between 1 and 4096");
945 }
946 let clamd = match env
947 .get("TEAMCRAFT_CLAMD_ADDR")?
948 .map(|a| a.trim().to_string())
949 .filter(|a| !a.is_empty())
950 {
951 Some(addr) => {
952 let unix = addr.strip_prefix("unix:");
953 if unix.is_some_and(|p| !p.starts_with('/'))
954 || (unix.is_none() && !addr.contains(':'))
955 {
956 bail!("TEAMCRAFT_CLAMD_ADDR must be host:port or unix:/path/to/clamd.socket");
957 }
958 Some(crate::malware::ScannerConfig {
959 addr,
960 max_bytes: clamd_max_mb * 1024 * 1024,
961 timeout: Duration::from_secs(120),
962 })
963 }
964 None => None,
965 };
966 let backup_passphrase = env
967 .get("TEAMCRAFT_BACKUP_PASSPHRASE")?
968 .map(|p| p.trim_end_matches(['\r', '\n']).to_string())
969 .filter(|p| !p.is_empty());
970 if backup_passphrase
971 .as_ref()
972 .is_some_and(|p| p.chars().count() < 12)
973 {
974 bail!("TEAMCRAFT_BACKUP_PASSPHRASE must have at least 12 characters");
975 }
976
977 let session_ttl_hours = env.parse_or("TEAMCRAFT_SESSION_TTL_HOURS", 72u64)?;
978 if session_ttl_hours == 0 {
979 bail!("TEAMCRAFT_SESSION_TTL_HOURS must be at least 1");
980 }
981
982 let signin_max_failures = env.parse_or("TEAMCRAFT_SIGNIN_MAX_FAILURES", 10u32)?;
983 let metrics_token = env
984 .get("TEAMCRAFT_METRICS_TOKEN")?
985 .map(|t| t.trim().to_string())
986 .filter(|t| !t.is_empty());
987 if metrics_token.as_ref().is_some_and(|t| t.len() < 16) {
988 bail!("TEAMCRAFT_METRICS_TOKEN must be at least 16 characters");
989 }
990
991 let approval_reauth = match env.get("TEAMCRAFT_APPROVAL_REAUTH")? {
992 None => crate::domain::ApprovalReauth::Off,
993 Some(raw) => crate::domain::ApprovalReauth::parse(&raw).ok_or_else(|| {
994 anyhow::anyhow!(
995 "TEAMCRAFT_APPROVAL_REAUTH must be off, release or every_decision (got '{raw}')"
996 )
997 })?,
998 };
999
1000 let oidc_token_idle_days = env.parse_or("TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS", 0u32)?;
1001 if oidc_token_idle_days > 3650 {
1002 bail!("TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS must be between 0 (off) and 3650");
1003 }
1004
1005 let audit_retention_days = env.parse_or("TEAMCRAFT_AUDIT_RETENTION_DAYS", 0u32)?;
1006 if audit_retention_days != 0
1007 && !(MIN_AUDIT_RETENTION_DAYS..=36_500).contains(&audit_retention_days)
1008 {
1009 bail!(
1010 "TEAMCRAFT_AUDIT_RETENTION_DAYS must be 0 (keep for ever) or between \
1011 {MIN_AUDIT_RETENTION_DAYS} and 36500"
1012 );
1013 }
1014
1015 let retention_latest_grace_days = env.parse_or(
1016 "TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS",
1017 crate::services::retention::DEFAULT_LATEST_GRACE_DAYS,
1018 )?;
1019 if retention_latest_grace_days > crate::services::retention::MAX_LATEST_GRACE_DAYS {
1020 bail!(
1021 "TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS must be between 0 (no automatic removal) and {}",
1022 crate::services::retention::MAX_LATEST_GRACE_DAYS
1023 );
1024 }
1025
1026 let cookie_secure = match env.get("TEAMCRAFT_COOKIE_SECURE")? {
1027 Some(v) => parse_bool(&v).context("TEAMCRAFT_COOKIE_SECURE must be true or false")?,
1028 None => public_url.starts_with("https://"),
1029 };
1030
1031 let log_format = match env
1032 .get("TEAMCRAFT_LOG_FORMAT")?
1033 .unwrap_or_else(|| "text".into())
1034 .to_ascii_lowercase()
1035 .as_str()
1036 {
1037 "text" => LogFormat::Text,
1038 "json" => LogFormat::Json,
1039 other => bail!("TEAMCRAFT_LOG_FORMAT must be 'text' or 'json', got '{other}'"),
1040 };
1041
1042 let bootstrap_admin = env
1043 .get("TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD")?
1044 .map(|password| -> Result<BootstrapAdmin> {
1045 let username = env
1046 .get("TEAMCRAFT_BOOTSTRAP_ADMIN_USERNAME")?
1047 .unwrap_or_else(|| "admin".to_string());
1048 Ok(BootstrapAdmin { username, password })
1049 })
1050 .transpose()?;
1051
1052 let oidc = oidc_config(&env)?;
1053 let local_login = match env.get("TEAMCRAFT_LOCAL_LOGIN")? {
1054 Some(v) => parse_bool(&v).context("TEAMCRAFT_LOCAL_LOGIN must be true or false")?,
1055 None => oidc.is_none(),
1056 };
1057 if oidc.is_none() && !local_login {
1058 bail!("no login method enabled: configure TEAMCRAFT_OIDC_ISSUER_URL or set TEAMCRAFT_LOCAL_LOGIN=true");
1059 }
1060
1061 let mattermost_error_webhook = env.get("MATTERMOST_ERROR_WEBHOOK")?;
1062 if let Some(url) = &mattermost_error_webhook {
1063 if !(url.starts_with("https://") || url.starts_with("http://")) {
1064 bail!("MATTERMOST_ERROR_WEBHOOK must be an http(s) URL");
1065 }
1066 }
1067
1068 let smtp = match env.get("TEAMCRAFT_SMTP_URL")? {
1069 Some(url) => {
1070 if !(url.starts_with("smtp://") || url.starts_with("smtps://")) {
1071 bail!("TEAMCRAFT_SMTP_URL must start with smtp:// or smtps://");
1072 }
1073 let from = env.get("TEAMCRAFT_MAIL_FROM")?.context(
1074 "TEAMCRAFT_MAIL_FROM (e.g. TeamCraft <teamcraft@example.org>) is required when TEAMCRAFT_SMTP_URL is set",
1075 )?;
1076 if from.parse::<lettre::message::Mailbox>().is_err() {
1077 bail!("TEAMCRAFT_MAIL_FROM is not a valid address: use 'Name <address>' or 'address'");
1078 }
1079 Some(SmtpConfig { url, from })
1080 }
1081 None => None,
1082 };
1083 let request_digest_hours = env.parse_or("TEAMCRAFT_REQUEST_DIGEST_HOURS", 24u64)?;
1084 if request_digest_hours > 24 * 30 {
1085 bail!("TEAMCRAFT_REQUEST_DIGEST_HOURS must be between 0 (off) and 720");
1086 }
1087 let webhook = match env.get("TEAMCRAFT_MATTERMOST_WEBHOOK")? {
1088 Some(webhook) => {
1089 if !(webhook.starts_with("https://") || webhook.starts_with("http://")) {
1090 bail!("TEAMCRAFT_MATTERMOST_WEBHOOK must be an http(s) URL");
1091 }
1092 Some(webhook)
1093 }
1094 None => None,
1095 };
1096 let bot = match (
1097 env.get("TEAMCRAFT_MATTERMOST_URL")?,
1098 env.get("TEAMCRAFT_MATTERMOST_BOT_TOKEN")?,
1099 ) {
1100 (Some(url), Some(token)) => {
1101 if !(url.starts_with("https://") || url.starts_with("http://")) {
1102 bail!("TEAMCRAFT_MATTERMOST_URL must be an http(s) URL");
1103 }
1104 Some(MattermostBot {
1105 url: url.trim().trim_end_matches('/').to_string(),
1106 token: token.trim().to_string(),
1107 })
1108 }
1109 (None, None) => None,
1110 _ => bail!(
1111 "TEAMCRAFT_MATTERMOST_URL and TEAMCRAFT_MATTERMOST_BOT_TOKEN must be set together"
1112 ),
1113 };
1114 let direct = match env.get("TEAMCRAFT_MATTERMOST_DIRECT")? {
1115 Some(v) => {
1116 parse_bool(&v).context("TEAMCRAFT_MATTERMOST_DIRECT must be true or false")?
1117 }
1118 None => false,
1119 };
1120 let mattermost = (bot.is_some() || webhook.is_some()).then_some(MattermostConfig {
1121 bot,
1122 webhook,
1123 direct,
1124 });
1125 let assistant = assistant_config(&env)?;
1126 let local_agents = match env.get("TEAMCRAFT_LOCAL_AGENTS")? {
1127 Some(raw) => LocalAgents::parse(&raw)?,
1128 None => LocalAgents::Allowed,
1129 };
1130
1131 let webhooks = WebhookConfig {
1132 allowed_hosts: match env.get("TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS")? {
1133 Some(raw) => parse_allowed_hosts(&raw)?,
1134 None => Vec::new(),
1135 },
1136 allow_private: match env.get("TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE")? {
1137 Some(v) => parse_bool(&v)
1138 .context("TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE must be true or false")?,
1139 None => false,
1140 },
1141 retention_days: env.parse_or(
1142 "TEAMCRAFT_WEBHOOK_RETENTION_DAYS",
1143 DEFAULT_WEBHOOK_RETENTION_DAYS,
1144 )?,
1145 };
1146 if !(1..=3650).contains(&webhooks.retention_days) {
1147 bail!("TEAMCRAFT_WEBHOOK_RETENTION_DAYS must be between 1 and 3650");
1148 }
1149
1150 Ok(Config {
1151 bind_addr,
1152 public_url,
1153 instance_name,
1154 database,
1155 database_password,
1156 database_display,
1157 db_max_connections,
1158 vault_dir,
1159 max_upload_bytes: max_upload_mb * 1024 * 1024,
1160 preview,
1161 preview_cache_max_bytes: preview_cache_max_mb * 1024 * 1024,
1162 upload_chunk_bytes: upload_chunk_mb * 1024 * 1024,
1163 clamd,
1164 backup_passphrase,
1165 session_ttl: Duration::from_secs(session_ttl_hours * 3600),
1166 approval_reauth,
1167 audit_retention_days,
1168 retention_latest_grace_days,
1169 signin_max_failures,
1170 oidc_token_idle_days,
1171 metrics_token,
1172 cookie_secure,
1173 log_format,
1174 bootstrap_admin,
1175 oidc,
1176 local_login,
1177 mattermost_error_webhook,
1178 smtp,
1179 request_digest_hours,
1180 mattermost,
1181 assistant,
1182 local_agents,
1183 webhooks,
1184 })
1185 }
1186
1187 pub fn summary(&self) -> String {
1189 format!(
1190 "bind={} public_url={} database={} vault={} max_upload={}MiB upload_chunk={}MiB malware_scan={} session_ttl={}h cookie_secure={} oidc={} local_login={} mattermost_errors={} smtp={} request_digest={}h mattermost_notifications={} assistant={} local_agents={} webhook_targets={}",
1191 self.bind_addr,
1192 self.public_url,
1193 self.database_display,
1194 self.vault_dir.display(),
1195 self.max_upload_bytes / (1024 * 1024),
1196 self.upload_chunk_bytes / (1024 * 1024),
1197 self.clamd.as_ref().map_or("off", |c| c.addr.as_str()),
1198 self.session_ttl.as_secs() / 3600,
1199 self.cookie_secure,
1200 self.oidc.as_ref().map(|o| o.issuer_url.as_str()).unwrap_or("disabled"),
1201 self.local_login,
1202 self.mattermost_error_webhook.is_some(),
1203 self.smtp.is_some(),
1204 self.request_digest_hours,
1205 self.mattermost
1206 .as_ref()
1207 .map_or("off", |m| {
1208 if m.bot.is_some() {
1209 "bot"
1210 } else if m.direct {
1211 "direct"
1212 } else {
1213 "channel"
1214 }
1215 }),
1216 self.assistant.as_ref().map_or_else(|| "off".to_string(), |a| a.label()),
1217 self.local_agents.as_str(),
1218 match (
1219 self.webhooks.allowed_hosts.is_empty(),
1220 self.webhooks.allow_private
1221 ) {
1222 (true, false) => "public",
1223 (true, true) => "any",
1224 (false, false) => "allow-list",
1225 (false, true) => "allow-list+private",
1226 },
1227 )
1228 }
1229}
1230
1231fn oidc_config<F: Fn(&str) -> Option<String>>(env: &Env<F>) -> Result<Option<OidcConfig>> {
1232 let Some(issuer_url) = env.get("TEAMCRAFT_OIDC_ISSUER_URL")? else {
1233 return Ok(None);
1234 };
1235 if !(issuer_url.starts_with("https://") || issuer_url.starts_with("http://")) {
1236 bail!("TEAMCRAFT_OIDC_ISSUER_URL must be an http(s) URL");
1237 }
1238 let client_id = env
1239 .get("TEAMCRAFT_OIDC_CLIENT_ID")?
1240 .context("TEAMCRAFT_OIDC_CLIENT_ID is required when OIDC is enabled")?;
1241 let client_secret = env.get("TEAMCRAFT_OIDC_CLIENT_SECRET")?.context(
1242 "TEAMCRAFT_OIDC_CLIENT_SECRET (or TEAMCRAFT_OIDC_CLIENT_SECRET_FILE) is required when OIDC is enabled",
1243 )?;
1244 Ok(Some(OidcConfig {
1245 issuer_url: issuer_url.trim().to_string(),
1246 client_id,
1247 client_secret,
1248 scopes: env
1249 .get("TEAMCRAFT_OIDC_SCOPES")?
1250 .unwrap_or_else(|| "openid profile email".into()),
1251 display_name: env
1252 .get("TEAMCRAFT_OIDC_DISPLAY_NAME")?
1253 .unwrap_or_else(|| "Single Sign-On".into()),
1254 groups_claim: env
1255 .get("TEAMCRAFT_OIDC_GROUPS_CLAIM")?
1256 .unwrap_or_else(|| "groups".into()),
1257 username_claim: env
1258 .get("TEAMCRAFT_OIDC_USERNAME_CLAIM")?
1259 .unwrap_or_else(|| "preferred_username".into()),
1260 admin_group: env
1261 .get("TEAMCRAFT_OIDC_ADMIN_GROUP")?
1262 .unwrap_or_else(|| "teamcraft-admins".into()),
1263 editor_group: env
1264 .get("TEAMCRAFT_OIDC_EDITOR_GROUP")?
1265 .unwrap_or_else(|| "teamcraft-editors".into()),
1266 viewer_group: env.get("TEAMCRAFT_OIDC_VIEWER_GROUP")?,
1267 }))
1268}
1269
1270struct Env<F: Fn(&str) -> Option<String>> {
1271 lookup: F,
1272}
1273
1274impl<F: Fn(&str) -> Option<String>> Env<F> {
1275 fn get(&self, key: &str) -> Result<Option<String>> {
1278 if let Some(path) = (self.lookup)(&format!("{key}_FILE")).filter(|p| !p.trim().is_empty()) {
1279 let content = std::fs::read_to_string(path.trim())
1280 .with_context(|| format!("cannot read {key}_FILE ({path})"))?;
1281 let value = content.trim_end_matches(['\n', '\r']).to_string();
1282 return Ok(Some(value).filter(|v| !v.is_empty()));
1283 }
1284 Ok((self.lookup)(key).filter(|v| !v.trim().is_empty()))
1285 }
1286
1287 fn parse_or<T>(&self, key: &str, default: T) -> Result<T>
1288 where
1289 T: FromStr,
1290 T::Err: std::fmt::Display,
1291 {
1292 match self.get(key)? {
1293 Some(raw) => raw
1294 .trim()
1295 .parse::<T>()
1296 .map_err(|e| anyhow!("{key}: invalid value '{raw}': {e}")),
1297 None => Ok(default),
1298 }
1299 }
1300}
1301
1302fn parse_bool(raw: &str) -> Result<bool> {
1303 match raw.trim().to_ascii_lowercase().as_str() {
1304 "1" | "true" | "yes" | "on" => Ok(true),
1305 "0" | "false" | "no" | "off" => Ok(false),
1306 other => bail!("not a boolean: '{other}'"),
1307 }
1308}
1309
1310fn percent_decode(component: &str) -> String {
1318 let bytes = component.as_bytes();
1319 let mut out = Vec::with_capacity(bytes.len());
1320 let hex = |b: Option<&u8>| b.and_then(|b| (*b as char).to_digit(16));
1321 let mut i = 0;
1322 while let Some(&b) = bytes.get(i) {
1323 match (b, hex(bytes.get(i + 1)), hex(bytes.get(i + 2))) {
1324 (b'%', Some(h), Some(l)) => {
1325 out.push((h * 16 + l) as u8);
1326 i += 3;
1327 }
1328 (b, _, _) => {
1329 out.push(b);
1330 i += 1;
1331 }
1332 }
1333 }
1334 String::from_utf8(out).unwrap_or_else(|_| component.to_string())
1335}
1336
1337fn database_options<F: Fn(&str) -> Option<String>>(
1338 env: &Env<F>,
1339) -> Result<(PgConnectOptions, String, Option<String>)> {
1340 let url = match env.get("TEAMCRAFT_DATABASE_URL")? {
1341 Some(url) => Some(url),
1342 None => env.get("DATABASE_URL")?,
1343 };
1344 if let Some(url) = url {
1345 let options = PgConnectOptions::from_str(&url)
1346 .context("TEAMCRAFT_DATABASE_URL is not a valid postgres:// URL")?
1347 .application_name("teamcraft");
1348 let display = format!(
1349 "{}:{}/{} (from URL)",
1350 options.get_host(),
1351 options.get_port(),
1352 options.get_database().unwrap_or("?")
1353 );
1354 let password = reqwest::Url::parse(&url)
1358 .ok()
1359 .and_then(|parsed| parsed.password().map(percent_decode))
1360 .filter(|password| !password.is_empty());
1361 return Ok((options, display, password));
1362 }
1363
1364 let host = env
1365 .get("TEAMCRAFT_DB_HOST")?
1366 .unwrap_or_else(|| "localhost".into());
1367 let port: u16 = env.parse_or("TEAMCRAFT_DB_PORT", 5432u16)?;
1368 let name = env
1369 .get("TEAMCRAFT_DB_NAME")?
1370 .unwrap_or_else(|| "teamcraft".into());
1371 let user = env
1372 .get("TEAMCRAFT_DB_USER")?
1373 .unwrap_or_else(|| "teamcraft".into());
1374 let password = env.get("TEAMCRAFT_DB_PASSWORD")?;
1377 let ssl_mode = match env.get("TEAMCRAFT_DB_SSLMODE")? {
1378 Some(mode) => {
1379 PgSslMode::from_str(&mode).map_err(|e| anyhow!("TEAMCRAFT_DB_SSLMODE: {e}"))?
1380 }
1381 None => PgSslMode::Prefer,
1382 };
1383
1384 let mut options = PgConnectOptions::new()
1385 .host(&host)
1386 .port(port)
1387 .username(&user)
1388 .database(&name)
1389 .ssl_mode(ssl_mode)
1390 .application_name("teamcraft");
1391 if let Some(password) = &password {
1392 options = options.password(password);
1393 }
1394 if host.starts_with('/') {
1397 options = options.socket(&host);
1398 }
1399 let display = format!("{user}@{host}:{port}/{name}");
1400 Ok((options, display, password))
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405 use super::*;
1406 use std::collections::HashMap;
1407
1408 fn config_from(pairs: &[(&str, &str)]) -> Result<Config> {
1409 let map: HashMap<String, String> = pairs
1410 .iter()
1411 .map(|(k, v)| (k.to_string(), v.to_string()))
1412 .collect();
1413 Config::from_lookup(move |k| map.get(k).cloned())
1414 }
1415
1416 #[test]
1417 fn defaults_with_password() {
1418 let cfg = config_from(&[("TEAMCRAFT_DB_PASSWORD", "secret")]).unwrap();
1419 assert_eq!(cfg.bind_addr.to_string(), "127.0.0.1:8080");
1420 assert_eq!(cfg.public_url, "http://localhost:8080");
1421 assert_eq!(cfg.max_upload_bytes, 512 * 1024 * 1024);
1422 assert_eq!(cfg.upload_chunk_bytes, 16 * 1024 * 1024);
1423 assert!(cfg.clamd.is_none(), "no malware scan by default");
1424 assert_eq!(cfg.request_digest_hours, 24, "daily digests");
1425 assert!(!cfg.cookie_secure);
1426 assert_eq!(cfg.log_format, LogFormat::Text);
1427 assert!(cfg.bootstrap_admin.is_none());
1428 assert_eq!(cfg.database_display, "teamcraft@localhost:5432/teamcraft");
1429 assert_eq!(cfg.database_password.as_deref(), Some("secret"));
1430 assert!(!cfg.summary().contains("secret"));
1431 }
1432
1433 #[test]
1434 fn malware_scanner_settings() {
1435 let cfg = config_from(&[
1436 ("TEAMCRAFT_DB_PASSWORD", "secret"),
1437 ("TEAMCRAFT_CLAMD_ADDR", "clamav:3310"),
1438 ("TEAMCRAFT_CLAMD_MAX_MB", "100"),
1439 ])
1440 .unwrap();
1441 let clamd = cfg.clamd.as_ref().unwrap();
1442 assert_eq!(clamd.addr, "clamav:3310");
1443 assert_eq!(clamd.max_bytes, 100 * 1024 * 1024);
1444 assert!(cfg.summary().contains("malware_scan=clamav:3310"));
1445 let unix = config_from(&[
1446 ("TEAMCRAFT_DB_PASSWORD", "secret"),
1447 ("TEAMCRAFT_CLAMD_ADDR", "unix:/run/clamav/clamd.ctl"),
1448 ])
1449 .unwrap();
1450 assert_eq!(unix.clamd.unwrap().max_bytes, 25 * 1024 * 1024);
1451 for bad in ["clamav", "unix:relative.sock"] {
1452 assert!(
1453 config_from(&[
1454 ("TEAMCRAFT_DB_PASSWORD", "secret"),
1455 ("TEAMCRAFT_CLAMD_ADDR", bad)
1456 ])
1457 .is_err(),
1458 "{bad}"
1459 );
1460 }
1461 assert!(config_from(&[
1462 ("TEAMCRAFT_DB_PASSWORD", "secret"),
1463 ("TEAMCRAFT_CLAMD_MAX_MB", "0")
1464 ])
1465 .is_err());
1466 }
1467
1468 #[test]
1469 fn database_url_password_is_decoded_for_the_client_tools() {
1470 let cfg = config_from(&[(
1471 "TEAMCRAFT_DATABASE_URL",
1472 "postgres://tc:p%40ss%3Aw%25rd%2F1@db:5432/teamcraft",
1473 )])
1474 .unwrap();
1475 assert_eq!(cfg.database_password.as_deref(), Some("p@ss:w%rd/1"));
1476 assert!(!cfg.summary().contains("p@ss") && !cfg.summary().contains("p%40ss"));
1477 let cfg = config_from(&[("TEAMCRAFT_DATABASE_URL", "postgres://tc:plain@db/x")]).unwrap();
1478 assert_eq!(cfg.database_password.as_deref(), Some("plain"));
1479 let cfg = config_from(&[("TEAMCRAFT_DATABASE_URL", "postgres://tc@db/x")]).unwrap();
1480 assert!(cfg.database_password.is_none());
1481
1482 assert_eq!(percent_decode("%C3%A4%20x"), "ä x");
1483 assert_eq!(percent_decode("100%"), "100%", "a trailing % stays");
1484 assert_eq!(percent_decode("%zz%4"), "%zz%4", "malformed escapes stay");
1485 assert_eq!(percent_decode("%FF"), "%FF", "no valid UTF-8: undecoded");
1486 }
1487
1488 #[test]
1489 fn notification_channels() {
1490 let base = [
1491 ("TEAMCRAFT_DB_PASSWORD", "secret"),
1492 ("TEAMCRAFT_LOCAL_LOGIN", "true"),
1493 ];
1494 let cfg = config_from(&base).unwrap();
1495 assert!(cfg.smtp.is_none() && cfg.mattermost.is_none(), "optional");
1496
1497 let with = |extra: &[(&'static str, &'static str)]| {
1498 let mut pairs = base.to_vec();
1499 pairs.extend_from_slice(extra);
1500 config_from(&pairs)
1501 };
1502 let cfg = with(&[
1503 (
1504 "TEAMCRAFT_SMTP_URL",
1505 "smtps://tc:mailpass@mail.example.org:465",
1506 ),
1507 ("TEAMCRAFT_MAIL_FROM", "TeamCraft <teamcraft@example.org>"),
1508 (
1509 "TEAMCRAFT_MATTERMOST_WEBHOOK",
1510 "https://chat.example.org/hooks/abc",
1511 ),
1512 ("TEAMCRAFT_MATTERMOST_DIRECT", "true"),
1513 ])
1514 .unwrap();
1515 assert_eq!(
1516 cfg.smtp.as_ref().unwrap().from,
1517 "TeamCraft <teamcraft@example.org>"
1518 );
1519 assert!(cfg.mattermost.as_ref().unwrap().direct);
1520 let summary = cfg.summary();
1521 assert!(
1522 summary.contains("smtp=true") && summary.contains("mattermost_notifications=direct")
1523 );
1524 assert!(
1525 !summary.contains("mailpass") && !summary.contains("hooks/abc"),
1526 "no secrets in the log"
1527 );
1528
1529 let err = with(&[("TEAMCRAFT_SMTP_URL", "smtp://mail.example.org")])
1530 .err()
1531 .unwrap();
1532 assert!(err.to_string().contains("TEAMCRAFT_MAIL_FROM"), "{err}");
1533 let err = with(&[
1534 ("TEAMCRAFT_SMTP_URL", "http://mail.example.org"),
1535 ("TEAMCRAFT_MAIL_FROM", "a@b.de"),
1536 ])
1537 .err()
1538 .unwrap();
1539 assert!(err.to_string().contains("smtp://"), "{err}");
1540 let err = with(&[
1541 ("TEAMCRAFT_SMTP_URL", "smtp://mail.example.org"),
1542 ("TEAMCRAFT_MAIL_FROM", "not an address"),
1543 ])
1544 .err()
1545 .unwrap();
1546 assert!(err.to_string().contains("TEAMCRAFT_MAIL_FROM"), "{err}");
1547 let cfg = with(&[
1548 ("TEAMCRAFT_MATTERMOST_URL", "https://chat.example.org/"),
1549 ("TEAMCRAFT_MATTERMOST_BOT_TOKEN", "bot-token-value"),
1550 ])
1551 .unwrap();
1552 let m = cfg.mattermost.as_ref().unwrap();
1553 assert!(m.is_direct() && !m.is_legacy_webhook());
1554 assert_eq!(m.bot.as_ref().unwrap().url, "https://chat.example.org");
1555 let summary = cfg.summary();
1556 assert!(summary.contains("mattermost_notifications=bot"));
1557 assert!(
1558 !summary.contains("bot-token-value"),
1559 "no secrets in the log"
1560 );
1561 assert!(with(&[("TEAMCRAFT_MATTERMOST_URL", "https://chat.example.org")]).is_err());
1562 assert!(with(&[("TEAMCRAFT_MATTERMOST_BOT_TOKEN", "x")]).is_err());
1563 assert!(with(&[
1564 ("TEAMCRAFT_MATTERMOST_URL", "chat.example.org"),
1565 ("TEAMCRAFT_MATTERMOST_BOT_TOKEN", "x"),
1566 ])
1567 .is_err());
1568 let legacy = with(&[(
1569 "TEAMCRAFT_MATTERMOST_WEBHOOK",
1570 "https://chat.example.org/hooks/x",
1571 )])
1572 .unwrap();
1573 assert!(legacy.mattermost.as_ref().unwrap().is_legacy_webhook());
1574 assert!(with(&[("TEAMCRAFT_MATTERMOST_WEBHOOK", "chat.example.org/hooks/x")]).is_err());
1575 assert!(with(&[
1576 (
1577 "TEAMCRAFT_MATTERMOST_WEBHOOK",
1578 "https://chat.example.org/hooks/x"
1579 ),
1580 ("TEAMCRAFT_MATTERMOST_DIRECT", "maybe"),
1581 ])
1582 .is_err());
1583 }
1584
1585 #[test]
1586 fn webhook_targets() {
1587 let cfg = config_from(&[]).unwrap();
1588 assert_eq!(cfg.webhooks, WebhookConfig::default());
1589 assert!(cfg.summary().contains("webhook_targets=public"));
1590 let cfg = config_from(&[
1591 (
1592 "TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS",
1593 "ERP.internal.example.org., *.ci.example.org [fd00::1] 10.0.0.5,erp.internal.example.org",
1594 ),
1595 ("TEAMCRAFT_WEBHOOK_RETENTION_DAYS", "7"),
1596 ])
1597 .unwrap();
1598 assert_eq!(
1599 cfg.webhooks.allowed_hosts,
1600 [
1601 "erp.internal.example.org",
1602 "*.ci.example.org",
1603 "fd00::1",
1604 "10.0.0.5"
1605 ]
1606 );
1607 assert_eq!(cfg.webhooks.retention_days, 7);
1608 assert!(cfg.summary().contains("webhook_targets=allow-list"));
1609 let cfg = config_from(&[("TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE", "yes")]).unwrap();
1610 assert!(cfg.webhooks.allow_private);
1611 for (key, value) in [
1612 ("TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS", "a.*.example.org"),
1613 ("TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS", "http://erp/x"),
1614 ("TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE", "sometimes"),
1615 ("TEAMCRAFT_WEBHOOK_RETENTION_DAYS", "0"),
1616 ] {
1617 assert!(config_from(&[(key, value)]).is_err(), "{key}={value}");
1618 }
1619 }
1620
1621 #[test]
1622 fn metrics_token_is_optional_trimmed_and_not_too_short() {
1623 assert!(config_from(&[]).unwrap().metrics_token.is_none());
1624 assert!(config_from(&[("TEAMCRAFT_METRICS_TOKEN", " ")])
1625 .unwrap()
1626 .metrics_token
1627 .is_none());
1628 let cfg = config_from(&[("TEAMCRAFT_METRICS_TOKEN", " 0123456789abcdef ")]).unwrap();
1629 assert_eq!(cfg.metrics_token.as_deref(), Some("0123456789abcdef"));
1630 assert!(config_from(&[("TEAMCRAFT_METRICS_TOKEN", "short")]).is_err());
1631 }
1632
1633 #[test]
1636 fn zero_configuration_uses_the_local_database() {
1637 let cfg = config_from(&[]).expect("no configuration needed");
1638 assert_eq!(cfg.database_display, "teamcraft@localhost:5432/teamcraft");
1639 assert!(cfg.local_login);
1640 assert_eq!(cfg.vault_dir, PathBuf::from("./data/vault"));
1641 assert!(cfg.database_password.is_none());
1642 }
1643
1644 #[test]
1646 fn unix_socket_database() {
1647 let cfg = config_from(&[
1648 ("TEAMCRAFT_DB_HOST", "/run/postgresql"),
1649 ("TEAMCRAFT_DB_USER", "teamcraft"),
1650 ])
1651 .unwrap();
1652 assert_eq!(
1653 cfg.database.get_socket().map(|p| p.display().to_string()),
1654 Some("/run/postgresql".to_string())
1655 );
1656 assert_eq!(
1657 cfg.database_display,
1658 "teamcraft@/run/postgresql:5432/teamcraft"
1659 );
1660 let file = include_str!("../packaging/linux/teamcraft.env");
1662 let vars: std::collections::HashMap<String, String> =
1663 dotenvy::from_read_iter(file.as_bytes())
1664 .map(|r| r.expect("valid line"))
1665 .collect();
1666 assert_eq!(vars["TEAMCRAFT_VAULT_DIR"], "/var/lib/teamcraft/vault");
1667 let packaged = Config::from_lookup(|k| vars.get(k).cloned()).expect("packaged config");
1668 assert!(
1669 packaged.bind_addr.ip().is_loopback(),
1670 "first-start setup works"
1671 );
1672 assert!(packaged.database.get_socket().is_some());
1673 }
1674
1675 #[test]
1678 fn docker_deployment_configuration() {
1679 let dir = std::env::temp_dir().join(format!("teamcraft-cfg-{}", uuid::Uuid::new_v4()));
1680 std::fs::create_dir_all(&dir).unwrap();
1681 let secret = dir.join("db_password");
1682 std::fs::write(&secret, "generated-password\n").unwrap();
1683
1684 let example = include_str!("../deploy/teamcraft.env.example");
1685 let mut vars: HashMap<String, String> = dotenvy::from_read_iter(example.as_bytes())
1686 .map(|r| r.expect("valid line"))
1687 .collect();
1688 let compose = include_str!("../deploy/docker-compose.yml");
1689 let mut from_compose = 0;
1690 for line in compose.lines().map(str::trim) {
1691 if let Some((key, value)) = line.split_once(": ") {
1692 if key.starts_with("TEAMCRAFT_") {
1693 vars.insert(key.to_string(), value.trim_matches('"').to_string());
1694 from_compose += 1;
1695 }
1696 }
1697 }
1698 assert!(from_compose >= 5, "compose environment found");
1699 assert_eq!(
1700 vars["TEAMCRAFT_DB_PASSWORD_FILE"], "/run/secrets/db_password",
1701 "the password comes from the Docker secret"
1702 );
1703 vars.insert(
1704 "TEAMCRAFT_DB_PASSWORD_FILE".into(),
1705 secret.to_str().unwrap().into(),
1706 );
1707
1708 let cfg = Config::from_lookup(|k| vars.get(k).cloned()).expect("docker config");
1709 assert_eq!(cfg.bind_addr.to_string(), "0.0.0.0:8080");
1710 assert_eq!(cfg.database_display, "teamcraft@db:5432/teamcraft");
1711 assert_eq!(cfg.vault_dir, PathBuf::from("/var/lib/teamcraft/vault"));
1712 assert!(cfg.local_login);
1713 assert!(!cfg.summary().contains("generated-password"));
1714 std::fs::remove_dir_all(dir).ok();
1715 }
1716
1717 #[test]
1718 fn url_and_https_detection() {
1719 let cfg = config_from(&[
1720 ("TEAMCRAFT_DATABASE_URL", "postgres://u:p@db:5433/plm"),
1721 ("TEAMCRAFT_PUBLIC_URL", "https://plm.example.org/"),
1722 ("TEAMCRAFT_LOG_FORMAT", "JSON"),
1723 ])
1724 .unwrap();
1725 assert_eq!(cfg.public_url, "https://plm.example.org");
1726 assert!(cfg.cookie_secure);
1727 assert_eq!(cfg.log_format, LogFormat::Json);
1728 assert_eq!(cfg.database_display, "db:5433/plm (from URL)");
1729 assert_eq!(cfg.database_password.as_deref(), Some("p"));
1730 }
1731
1732 #[test]
1733 fn secrets_from_files() {
1734 let dir = std::env::temp_dir().join(format!("teamcraft-cfg-{}", uuid::Uuid::new_v4()));
1735 std::fs::create_dir_all(&dir).unwrap();
1736 let file = dir.join("admin_pw");
1737 std::fs::write(&file, "from-file-password\n").unwrap();
1738 let cfg = config_from(&[
1739 ("TEAMCRAFT_DB_PASSWORD", "x"),
1740 (
1741 "TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD_FILE",
1742 file.to_str().unwrap(),
1743 ),
1744 ])
1745 .unwrap();
1746 let admin = cfg.bootstrap_admin.expect("bootstrap admin");
1747 assert_eq!(admin.username, "admin");
1748 assert_eq!(admin.password, "from-file-password");
1749 std::fs::remove_dir_all(dir).ok();
1750 }
1751
1752 #[test]
1753 fn oidc_settings() {
1754 let cfg = config_from(&[
1755 ("TEAMCRAFT_DB_PASSWORD", "x"),
1756 (
1757 "TEAMCRAFT_OIDC_ISSUER_URL",
1758 "https://auth.example.org/application/o/teamcraft/",
1759 ),
1760 ("TEAMCRAFT_OIDC_CLIENT_ID", "client"),
1761 ("TEAMCRAFT_OIDC_CLIENT_SECRET", "s3cret"),
1762 ])
1763 .unwrap();
1764 let oidc = cfg.oidc.as_ref().expect("oidc enabled");
1765 assert_eq!(oidc.admin_group, "teamcraft-admins");
1766 assert_eq!(oidc.editor_group, "teamcraft-editors");
1767 assert!(oidc.viewer_group.is_none());
1768 assert_eq!(
1769 oidc.redirect_uri(&cfg.public_url),
1770 "http://localhost:8080/auth/oidc/callback"
1771 );
1772 assert!(!cfg.local_login);
1774 assert!(!cfg.summary().contains("s3cret"));
1775
1776 assert!(config_from(&[
1778 ("TEAMCRAFT_DB_PASSWORD", "x"),
1779 ("TEAMCRAFT_OIDC_ISSUER_URL", "https://auth.example.org/"),
1780 ("TEAMCRAFT_OIDC_CLIENT_ID", "client"),
1781 ])
1782 .is_err());
1783 }
1784
1785 #[test]
1786 fn some_login_method_is_required() {
1787 let defaults = config_from(&[("TEAMCRAFT_DB_PASSWORD", "x")]).unwrap();
1788 assert!(defaults.local_login);
1789 assert!(config_from(&[
1790 ("TEAMCRAFT_DB_PASSWORD", "x"),
1791 ("TEAMCRAFT_LOCAL_LOGIN", "false")
1792 ])
1793 .is_err());
1794 }
1795
1796 #[test]
1797 fn invalid_values_are_rejected() {
1798 assert!(config_from(&[
1799 ("TEAMCRAFT_DB_PASSWORD", "x"),
1800 ("TEAMCRAFT_BIND_ADDR", "nope")
1801 ])
1802 .is_err());
1803 assert!(config_from(&[
1804 ("TEAMCRAFT_DB_PASSWORD", "x"),
1805 ("TEAMCRAFT_PUBLIC_URL", "ftp://x")
1806 ])
1807 .is_err());
1808 assert!(config_from(&[
1809 ("TEAMCRAFT_DB_PASSWORD", "x"),
1810 ("TEAMCRAFT_MAX_UPLOAD_MB", "0")
1811 ])
1812 .is_err());
1813 assert!(config_from(&[
1814 ("TEAMCRAFT_DB_PASSWORD", "x"),
1815 ("TEAMCRAFT_UPLOAD_CHUNK_MB", "0")
1816 ])
1817 .is_err());
1818 assert!(config_from(&[
1819 ("TEAMCRAFT_DB_PASSWORD", "x"),
1820 ("TEAMCRAFT_COOKIE_SECURE", "maybe")
1821 ])
1822 .is_err());
1823 assert!(config_from(&[
1824 ("TEAMCRAFT_DB_PASSWORD", "x"),
1825 ("TEAMCRAFT_REQUEST_DIGEST_HOURS", "721")
1826 ])
1827 .is_err());
1828 let off = config_from(&[
1829 ("TEAMCRAFT_DB_PASSWORD", "x"),
1830 ("TEAMCRAFT_REQUEST_DIGEST_HOURS", "0"),
1831 ])
1832 .unwrap();
1833 assert_eq!(off.request_digest_hours, 0, "0 switches the digest off");
1834 }
1835
1836 #[test]
1837 fn preview_limits_from_the_environment() {
1838 let d = config_from(&[("TEAMCRAFT_DB_PASSWORD", "x")])
1839 .unwrap()
1840 .preview;
1841 assert_eq!(d, crate::preview::PreviewLimits::default());
1842 assert_eq!(d.max_result_bytes, 64 * 1024 * 1024);
1843 assert_eq!(d.max_memory_bytes, 1024 * 1024 * 1024);
1844 let c = config_from(&[
1845 ("TEAMCRAFT_DB_PASSWORD", "x"),
1846 ("TEAMCRAFT_PREVIEW_MAX_RESULT_MB", "8"),
1847 ("TEAMCRAFT_PREVIEW_MAX_MEMORY_MB", "0"),
1848 ])
1849 .unwrap()
1850 .preview;
1851 assert_eq!(c.max_result_bytes, 8 * 1024 * 1024);
1852 assert_eq!(c.max_memory_bytes, 0, "0 = no limit");
1853 for bad in [
1854 ("TEAMCRAFT_PREVIEW_MAX_RESULT_MB", "0"),
1855 ("TEAMCRAFT_PREVIEW_MAX_MESH_MB", "x"),
1856 ("TEAMCRAFT_PREVIEW_MAX_STEP_MB", "0"),
1857 ] {
1858 assert!(config_from(&[("TEAMCRAFT_DB_PASSWORD", "x"), bad]).is_err());
1859 }
1860 }
1861
1862 #[test]
1863 fn latest_retention_grace_days() {
1864 let d = config_from(&[("TEAMCRAFT_DB_PASSWORD", "x")]).unwrap();
1865 assert_eq!(d.retention_latest_grace_days, 7);
1866 for (raw, want) in [("0", 0), ("1", 1), ("3650", 3650)] {
1867 let c = config_from(&[
1868 ("TEAMCRAFT_DB_PASSWORD", "x"),
1869 ("TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS", raw),
1870 ])
1871 .unwrap();
1872 assert_eq!(c.retention_latest_grace_days, want, "{raw}");
1873 }
1874 for bad in ["3651", "-1", "x"] {
1875 assert!(
1876 config_from(&[
1877 ("TEAMCRAFT_DB_PASSWORD", "x"),
1878 ("TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS", bad)
1879 ])
1880 .is_err(),
1881 "{bad}"
1882 );
1883 }
1884 }
1885
1886 #[test]
1887 fn retention_settings() {
1888 let d = config_from(&[("TEAMCRAFT_DB_PASSWORD", "x")]).unwrap();
1889 assert_eq!(d.audit_retention_days, 0, "the audit log is kept for ever");
1890 assert_eq!(
1891 d.preview_cache_max_bytes, 0,
1892 "the preview cache has no limit"
1893 );
1894 let c = config_from(&[
1895 ("TEAMCRAFT_DB_PASSWORD", "x"),
1896 ("TEAMCRAFT_AUDIT_RETENTION_DAYS", "3650"),
1897 ("TEAMCRAFT_PREVIEW_CACHE_MAX_MB", "2048"),
1898 ])
1899 .unwrap();
1900 assert_eq!(c.audit_retention_days, 3650);
1901 assert_eq!(c.preview_cache_max_bytes, 2048 * 1024 * 1024);
1902 for bad in [
1903 ("TEAMCRAFT_AUDIT_RETENTION_DAYS", "1"),
1904 ("TEAMCRAFT_AUDIT_RETENTION_DAYS", "29"),
1905 ("TEAMCRAFT_AUDIT_RETENTION_DAYS", "36501"),
1906 ("TEAMCRAFT_AUDIT_RETENTION_DAYS", "-1"),
1907 ("TEAMCRAFT_PREVIEW_CACHE_MAX_MB", "x"),
1908 ("TEAMCRAFT_PREVIEW_CACHE_MAX_MB", "16777217"),
1909 ] {
1910 assert!(
1911 config_from(&[("TEAMCRAFT_DB_PASSWORD", "x"), bad]).is_err(),
1912 "{bad:?}"
1913 );
1914 }
1915 }
1916
1917 #[test]
1918 fn local_agents_switch() {
1919 let base = ("TEAMCRAFT_DB_PASSWORD", "x");
1920 let cfg = config_from(&[base]).unwrap();
1922 assert_eq!(cfg.local_agents, LocalAgents::Allowed);
1923 assert!(cfg.summary().contains("local_agents=allowed"));
1924 for (raw, want) in [
1925 ("allowed", LocalAgents::Allowed),
1926 ("off", LocalAgents::Off),
1927 (" OFF ", LocalAgents::Off),
1928 ("Allowed", LocalAgents::Allowed),
1929 ("", LocalAgents::Allowed),
1931 ] {
1932 let cfg = config_from(&[base, ("TEAMCRAFT_LOCAL_AGENTS", raw)]).unwrap();
1933 assert_eq!(cfg.local_agents, want, "{raw:?}");
1934 }
1935 let off = config_from(&[base, ("TEAMCRAFT_LOCAL_AGENTS", "off")]).unwrap();
1936 assert!(!off.local_agents.allowed());
1937 assert!(off.summary().contains("local_agents=off"));
1938 for bad in ["false", "no", "disabled", "on", "of"] {
1940 let err = config_from(&[base, ("TEAMCRAFT_LOCAL_AGENTS", bad)])
1941 .err()
1942 .unwrap_or_else(|| panic!("{bad:?} accepted"));
1943 assert!(err.to_string().contains("TEAMCRAFT_LOCAL_AGENTS"), "{err}");
1944 }
1945 }
1946
1947 #[test]
1948 fn local_agents_file_variant() {
1949 let dir = std::env::temp_dir().join(format!("tc-local-agents-{}", std::process::id()));
1950 std::fs::create_dir_all(&dir).unwrap();
1951 let file = dir.join("local_agents");
1952 std::fs::write(&file, "off\n").unwrap();
1953 let path = file.to_string_lossy().to_string();
1954 let cfg = config_from(&[
1955 ("TEAMCRAFT_DB_PASSWORD", "x"),
1956 ("TEAMCRAFT_LOCAL_AGENTS_FILE", &path),
1957 ])
1958 .unwrap();
1959 assert_eq!(cfg.local_agents, LocalAgents::Off);
1960 let _ = std::fs::remove_dir_all(&dir);
1961 }
1962
1963 fn assistant_from(pairs: &[(&str, &str)]) -> Result<Option<AssistantConfig>> {
1964 let mut all = vec![("TEAMCRAFT_DB_PASSWORD", "x")];
1965 all.extend_from_slice(pairs);
1966 config_from(&all).map(|c| c.assistant)
1967 }
1968
1969 #[test]
1970 fn assistant_providers() {
1971 use AssistantProvider::*;
1972 let a = assistant_from(&[("TEAMCRAFT_ANTHROPIC_API_KEY", "k")])
1974 .unwrap()
1975 .unwrap();
1976 assert_eq!(a.provider, Anthropic);
1977 assert_eq!(a.base_url, "https://api.anthropic.com/v1");
1978 assert_eq!(a.api_key.as_deref(), Some("k"));
1979 assert_eq!(a.model, DEFAULT_ASSISTANT_MODEL);
1980 assert_eq!(a.max_tokens, 16_000);
1981 assert_eq!(a.request_timeout, Duration::from_secs(180));
1982 assert_eq!(a.run_timeout, Duration::from_secs(240));
1983 assert_eq!(a.max_concurrent, 4);
1984 assert_eq!(a.label(), "anthropic:claude-opus-5-5@api.anthropic.com");
1985
1986 let new = [("TEAMCRAFT_ASSISTANT_API_KEY", "k")];
1988 assert_eq!(assistant_from(&new).unwrap().unwrap().provider, Anthropic);
1989 assert!(assistant_from(&[
1990 ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
1991 ("TEAMCRAFT_ANTHROPIC_API_KEY", "k"),
1992 ])
1993 .unwrap()
1994 .is_some());
1995 let conflict = assistant_from(&[
1996 ("TEAMCRAFT_ASSISTANT_API_KEY", "one"),
1997 ("TEAMCRAFT_ANTHROPIC_API_KEY", "two"),
1998 ])
1999 .unwrap_err()
2000 .to_string();
2001 assert!(conflict.contains("different values"));
2002 assert!(
2003 !conflict.contains("one") && !conflict.contains("two"),
2004 "no key in the error"
2005 );
2006
2007 assert!(assistant_from(&[
2009 ("TEAMCRAFT_ASSISTANT_PROVIDER", "openai"),
2010 ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
2011 ])
2012 .is_err());
2013 let a = assistant_from(&[
2014 ("TEAMCRAFT_ASSISTANT_PROVIDER", "OpenAI"),
2015 ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
2016 ("TEAMCRAFT_ASSISTANT_MODEL", "gpt-5.4"),
2017 ])
2018 .unwrap()
2019 .unwrap();
2020 assert_eq!(
2021 (a.provider, a.base_url.as_str()),
2022 (OpenAi, "https://api.openai.com/v1")
2023 );
2024 assert!(
2025 assistant_from(&[
2026 ("TEAMCRAFT_ASSISTANT_PROVIDER", "openai"),
2027 ("TEAMCRAFT_ASSISTANT_MODEL", "gpt-5.4"),
2028 ])
2029 .unwrap()
2030 .is_none(),
2031 "no key: disabled"
2032 );
2033
2034 let local = [
2036 ("TEAMCRAFT_ASSISTANT_PROVIDER", "openai-compatible"),
2037 (
2038 "TEAMCRAFT_ASSISTANT_BASE_URL",
2039 "http://ollama.internal:11434/v1/",
2040 ),
2041 ("TEAMCRAFT_ASSISTANT_MODEL", "llama3.1:8b"),
2042 ];
2043 let a = assistant_from(&local).unwrap().unwrap();
2044 assert_eq!(a.provider, OpenAiCompatible);
2045 assert_eq!(
2046 a.base_url, "http://ollama.internal:11434/v1",
2047 "trailing slash dropped"
2048 );
2049 assert!(a.api_key.is_none());
2050 assert_eq!(a.label(), "openai-compatible:llama3.1:8b@ollama.internal");
2051 assert!(
2052 a.insecure_key_warning().is_none(),
2053 "no key, nothing to leak"
2054 );
2055 assert!(
2056 assistant_from(&[local[0], ("TEAMCRAFT_ASSISTANT_API_KEY", "k"), local[2]]).is_err(),
2057 "base URL required"
2058 );
2059 assert!(assistant_from(&local[..2]).is_err(), "model required");
2060 assert!(
2061 assistant_from(&[
2062 ("TEAMCRAFT_ASSISTANT_PROVIDER", "openai-compatible"),
2063 ("TEAMCRAFT_ASSISTANT_MODEL", "m")
2064 ])
2065 .unwrap()
2066 .is_none(),
2067 "neither key nor URL: disabled"
2068 );
2069 assert!(assistant_from(&[("TEAMCRAFT_ASSISTANT_PROVIDER", "gemini")]).is_err());
2070
2071 let a = assistant_from(&[
2073 ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
2074 ("TEAMCRAFT_ASSISTANT_MAX_TOKENS", "4000"),
2075 ("TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT", "600"),
2076 ("TEAMCRAFT_ASSISTANT_MAX_CONCURRENT", "2"),
2077 ("TEAMCRAFT_ASSISTANT_RETENTION_DAYS", "7"),
2078 ])
2079 .unwrap()
2080 .unwrap();
2081 assert_eq!(a.max_tokens, 4000);
2082 assert_eq!(a.request_timeout, Duration::from_secs(600));
2083 assert_eq!(a.run_timeout, Duration::from_secs(600));
2084 assert_eq!(a.max_concurrent, 2);
2085 assert_eq!(a.retention_days, 7);
2086 for (key, value) in [
2087 ("TEAMCRAFT_ASSISTANT_MAX_TOKENS", "10"),
2088 ("TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT", "1"),
2089 ("TEAMCRAFT_ASSISTANT_RUN_TIMEOUT", "100"), ("TEAMCRAFT_ASSISTANT_MAX_CONCURRENT", "0"),
2091 ("TEAMCRAFT_ASSISTANT_RETENTION_DAYS", "0"),
2092 ("TEAMCRAFT_ASSISTANT_RETENTION_DAYS", "366"),
2093 ] {
2094 assert!(
2095 assistant_from(&[("TEAMCRAFT_ASSISTANT_API_KEY", "k"), (key, value)]).is_err(),
2096 "{key}={value}"
2097 );
2098 }
2099 }
2100
2101 #[test]
2102 fn internal_urls_are_parsed_and_matched() {
2103 let base = [
2104 ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
2105 ("TEAMCRAFT_ASSISTANT_PROVIDER", "openai-compatible"),
2106 ("TEAMCRAFT_ASSISTANT_MODEL", "m"),
2107 (
2108 "TEAMCRAFT_ASSISTANT_BASE_URL",
2109 "http://ollama.internal:11434/v1",
2110 ),
2111 ];
2112 let a = assistant_from(&base).unwrap().unwrap();
2113 assert!(a.internal_urls.is_empty() && !a.is_internal());
2114 let mut with = base.to_vec();
2115 with.push((
2116 "TEAMCRAFT_ASSISTANT_INTERNAL_URLS",
2117 "https://other.example, http://OLLAMA.internal:11434/",
2118 ));
2119 let a = assistant_from(&with).unwrap().unwrap();
2120 assert_eq!(a.internal_urls.len(), 2);
2121 assert!(a.is_internal());
2122 let mut elsewhere = base.to_vec();
2123 elsewhere.push((
2124 "TEAMCRAFT_ASSISTANT_INTERNAL_URLS",
2125 "http://ollama.internal:9999",
2126 ));
2127 assert!(!assistant_from(&elsewhere).unwrap().unwrap().is_internal());
2128 for bad in [
2129 "not a url",
2130 "ftp://x.internal",
2131 "https://u:p@x.internal",
2132 "https://x.internal/?q=1",
2133 ] {
2134 let mut v = base.to_vec();
2135 v.push(("TEAMCRAFT_ASSISTANT_INTERNAL_URLS", bad));
2136 assert!(assistant_from(&v).is_err(), "{bad}");
2137 }
2138 }
2139
2140 #[test]
2141 fn url_prefix_matching_respects_segments_scheme_and_port() {
2142 assert!(url_is_under("https://a.example/v1", "https://a.example"));
2143 assert!(url_is_under(
2144 "https://a.example/v1/x",
2145 "https://a.example/v1/"
2146 ));
2147 assert!(url_is_under(
2148 "https://A.example:443/v1",
2149 "https://a.example/v1"
2150 ));
2151 assert!(!url_is_under(
2152 "https://a.example/v10",
2153 "https://a.example/v1"
2154 ));
2155 assert!(!url_is_under("http://a.example/v1", "https://a.example"));
2156 assert!(!url_is_under(
2157 "https://a.example.evil/v1",
2158 "https://a.example"
2159 ));
2160 assert!(!url_is_under("https://a.example:8443", "https://a.example"));
2161 }
2162
2163 #[test]
2164 fn assistant_base_urls_are_checked() {
2165 let with = |url: &str| {
2166 assistant_from(&[
2167 ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
2168 ("TEAMCRAFT_ASSISTANT_BASE_URL", url),
2169 ])
2170 };
2171 assert!(with("https://api.example.com/v1").is_ok());
2172 for bad in [
2173 "ftp://example.com",
2174 "file:///etc/passwd",
2175 "https://user:pass@example.com/v1",
2176 "https://user@example.com/v1",
2177 "https://example.com/v1?key=1",
2178 "not a url",
2179 "https://",
2180 ] {
2181 let e = with(bad);
2182 assert!(e.is_err(), "{bad}");
2183 assert!(
2184 !e.unwrap_err().to_string().contains("pass@"),
2185 "no credentials in the error"
2186 );
2187 }
2188
2189 let warn = |url: &str| with(url).unwrap().unwrap().insecure_key_warning();
2191 assert!(warn("https://llm.example.com/v1").is_none());
2192 assert!(warn("http://localhost:11434/v1").is_none());
2193 assert!(warn("http://127.0.0.1:8000/v1").is_none());
2194 assert!(warn("http://[::1]:8000/v1").is_none());
2195 let w = warn("http://llm.example.com/v1").unwrap();
2196 assert!(w.contains("plain http") && w.contains("llm.example.com"));
2197 assert!(!w.contains("k "), "no key in the warning");
2198 assert!(
2199 warn("http://10.0.0.5:8000/v1").is_some(),
2200 "private is not loopback"
2201 );
2202 }
2203
2204 #[test]
2205 fn assistant_settings() {
2206 let cfg = config_from(&[("TEAMCRAFT_DB_PASSWORD", "x")]).unwrap();
2207 assert!(cfg.assistant.is_none(), "disabled without a key");
2208 assert!(cfg.summary().contains("assistant=off"));
2209
2210 let cfg = config_from(&[
2211 ("TEAMCRAFT_DB_PASSWORD", "x"),
2212 ("TEAMCRAFT_ANTHROPIC_API_KEY", "sk-test-key-123"),
2213 ])
2214 .unwrap();
2215 let a = cfg.assistant.as_ref().unwrap();
2216 assert_eq!(a.model, DEFAULT_ASSISTANT_MODEL);
2217 assert_eq!(a.max_steps, DEFAULT_ASSISTANT_MAX_STEPS);
2218 assert_eq!(a.retention_days, DEFAULT_ASSISTANT_RETENTION_DAYS);
2219 assert!(
2220 !cfg.summary().contains("sk-test-key-123"),
2221 "no key in the log"
2222 );
2223 assert!(
2224 !format!("{a:?}").contains("sk-test-key-123"),
2225 "no key in Debug"
2226 );
2227
2228 let cfg = config_from(&[
2229 ("TEAMCRAFT_DB_PASSWORD", "x"),
2230 ("TEAMCRAFT_ANTHROPIC_API_KEY", "k"),
2231 ("TEAMCRAFT_ASSISTANT_MODEL", "claude-sonnet-5-5"),
2232 ("TEAMCRAFT_ASSISTANT_MAX_STEPS", "5"),
2233 ])
2234 .unwrap();
2235 let a = cfg.assistant.unwrap();
2236 assert_eq!((a.model.as_str(), a.max_steps), ("claude-sonnet-5-5", 5));
2237
2238 let dir = std::env::temp_dir().join(format!("teamcraft-cfg-{}", uuid::Uuid::new_v4()));
2240 std::fs::create_dir_all(&dir).unwrap();
2241 let blank = dir.join("blank");
2242 std::fs::write(&blank, " \n").unwrap();
2243 let cfg = config_from(&[
2244 ("TEAMCRAFT_DB_PASSWORD", "x"),
2245 ("TEAMCRAFT_ANTHROPIC_API_KEY_FILE", blank.to_str().unwrap()),
2246 ])
2247 .unwrap();
2248 assert!(cfg.assistant.is_none(), "blank key file = disabled");
2249 let cfg = config_from(&[
2250 ("TEAMCRAFT_DB_PASSWORD", "x"),
2251 ("TEAMCRAFT_ANTHROPIC_API_KEY", "k"),
2252 ("TEAMCRAFT_ASSISTANT_MODEL_FILE", blank.to_str().unwrap()),
2253 ])
2254 .unwrap();
2255 assert_eq!(cfg.assistant.unwrap().model, DEFAULT_ASSISTANT_MODEL);
2256 std::fs::remove_dir_all(dir).ok();
2257
2258 for (key, value) in [
2259 ("TEAMCRAFT_ASSISTANT_MAX_STEPS", "0"),
2260 ("TEAMCRAFT_ASSISTANT_RETENTION_DAYS", "0"),
2261 ("TEAMCRAFT_ASSISTANT_RETENTION_DAYS", "400"),
2262 ("TEAMCRAFT_ASSISTANT_MAX_STEPS", "500"),
2263 ("TEAMCRAFT_ASSISTANT_MODEL", "claude opus"),
2264 ] {
2265 assert!(
2266 config_from(&[
2267 ("TEAMCRAFT_DB_PASSWORD", "x"),
2268 ("TEAMCRAFT_ANTHROPIC_API_KEY", "k"),
2269 (key, value),
2270 ])
2271 .is_err(),
2272 "{key}={value}"
2273 );
2274 }
2275 }
2276
2277 fn names(list: &[&str]) -> Vec<String> {
2278 list.iter().map(|s| s.to_string()).collect()
2279 }
2280
2281 #[test]
2282 fn unknown_variables_are_found_with_a_suggestion() {
2283 let found = unknown_variables(names(&[
2284 "TEAMCRAFT_PUBLIC_URL",
2285 "TEAMCRAFT_PUBLIC_URL_FILE",
2286 "TEAMCRAFT_PUBLC_URL",
2287 "TEAMCRAFT_DB_PASSWORD_FIL",
2288 "TEAMCRAFT_TOTALLY_DIFFERENT_THING_X",
2289 "TEAMCRAFT_REQUIRE_DB_TESTS",
2290 "TEAMCRAFT_ENV_FILE",
2291 "PATH",
2292 "MATTERMOST_ERROR_WEBHOK",
2293 ]));
2294 let get = |n: &str| found.iter().find(|(k, _)| k == n).map(|(_, h)| h.clone());
2295 assert_eq!(found.len(), 4, "{found:?}");
2296 assert_eq!(
2297 get("TEAMCRAFT_PUBLC_URL"),
2298 Some(Some("TEAMCRAFT_PUBLIC_URL".into()))
2299 );
2300 assert_eq!(
2301 get("TEAMCRAFT_DB_PASSWORD_FIL"),
2302 Some(Some("TEAMCRAFT_DB_PASSWORD".into()))
2303 );
2304 assert_eq!(
2305 get("MATTERMOST_ERROR_WEBHOK"),
2306 Some(Some("MATTERMOST_ERROR_WEBHOOK".into()))
2307 );
2308 assert_eq!(get("TEAMCRAFT_TOTALLY_DIFFERENT_THING_X"), Some(None));
2309 assert_eq!(get("TEAMCRAFT_PUBLIC_URL_FILE"), None);
2311 assert_eq!(get("TEAMCRAFT_REQUIRE_DB_TESTS"), None);
2312 assert_eq!(get("TEAMCRAFT_ENV_FILE"), None);
2313 assert_eq!(get("PATH"), None);
2314 }
2315
2316 #[test]
2317 fn unknown_file_variable_suggests_the_file_form() {
2318 assert_eq!(
2319 unknown_variables(names(&["TEAMCRAFT_DB_PASWORD_FILE"])),
2320 vec![(
2321 "TEAMCRAFT_DB_PASWORD_FILE".to_string(),
2322 Some("TEAMCRAFT_DB_PASSWORD_FILE".to_string())
2323 )]
2324 );
2325 }
2326
2327 #[test]
2328 fn edit_distance_basics() {
2329 assert_eq!(edit_distance("", ""), 0);
2330 assert_eq!(edit_distance("abc", "abc"), 0);
2331 assert_eq!(edit_distance("abc", ""), 3);
2332 assert_eq!(edit_distance("", "abc"), 3);
2333 assert_eq!(edit_distance("kitten", "sitting"), 3);
2334 }
2335
2336 fn variable_words(text: &str, quoted: bool) -> std::collections::BTreeSet<String> {
2339 let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_';
2340 let mut out = std::collections::BTreeSet::new();
2341 let mut word_start: Option<usize> = None;
2342 for (pos, c) in text.char_indices().chain([(text.len(), ' ')]) {
2344 if is_word(c) {
2345 word_start.get_or_insert(pos);
2346 continue;
2347 }
2348 let Some(start) = word_start.take() else {
2349 continue;
2350 };
2351 let word = text.get(start..pos).unwrap_or("");
2352 let before = text.get(..start).and_then(|t| t.chars().last());
2353 let named = word.starts_with("TEAMCRAFT_")
2354 || word.starts_with("MATTERMOST_")
2355 || word == "DATABASE_URL";
2356 let literal = before == Some('"') && c == '"';
2357 if named && (literal || !quoted) && !word.chars().any(|c| c.is_ascii_lowercase()) {
2358 out.insert(word.to_string());
2359 }
2360 }
2361 out
2362 }
2363
2364 #[test]
2365 fn variable_words_scanner() {
2366 let words = variable_words("a \"TEAMCRAFT_A\" `TEAMCRAFT_B_*` TEAMCRAFT_c x", false);
2367 assert_eq!(
2368 words.into_iter().collect::<Vec<_>>(),
2369 vec!["TEAMCRAFT_A".to_string(), "TEAMCRAFT_B_".to_string()]
2370 );
2371 let words = variable_words("\"TEAMCRAFT_A\" TEAMCRAFT_B TEAMCRAFT_C", true);
2372 assert_eq!(words.into_iter().collect::<Vec<_>>(), vec!["TEAMCRAFT_A"]);
2373 }
2374
2375 #[test]
2376 fn known_variables_match_the_source() {
2377 let source = include_str!("config.rs");
2378 let production = source.split("mod tests {").next().unwrap_or(source);
2379 let read: std::collections::BTreeSet<String> = variable_words(production, true)
2380 .into_iter()
2381 .filter(|w| !w.ends_with('_') && !TOOLING_VARIABLES.contains(&w.as_str()))
2382 .collect();
2383 let known: std::collections::BTreeSet<String> =
2384 KNOWN_VARIABLES.iter().map(|s| s.to_string()).collect();
2385 let cli_only = ["TEAMCRAFT_SERVER", "TEAMCRAFT_TOKEN"];
2387 let missing: Vec<_> = read.difference(&known).collect();
2388 assert!(
2389 missing.is_empty(),
2390 "read by config.rs, not in KNOWN_VARIABLES: {missing:?}"
2391 );
2392 let stale: Vec<_> = known
2393 .difference(&read)
2394 .filter(|k| !cli_only.contains(&k.as_str()))
2395 .collect();
2396 assert!(
2397 stale.is_empty(),
2398 "in KNOWN_VARIABLES, not read by config.rs: {stale:?}"
2399 );
2400 assert!(
2401 KNOWN_VARIABLES.windows(2).all(|w| w[0] < w[1]),
2402 "keep KNOWN_VARIABLES sorted"
2403 );
2404 for tool in TOOLING_VARIABLES {
2405 assert!(!known.contains(*tool), "{tool} is in both lists");
2406 }
2407 }
2408
2409 #[test]
2410 fn documentation_and_known_variables_agree() {
2411 let docs = include_str!("../docs/src/admin/configuration.md");
2412 let words = variable_words(docs, false);
2413 let (prefixes, full): (Vec<_>, Vec<_>) = words.iter().partition(|w| w.ends_with('_'));
2414 let undocumented: Vec<_> = KNOWN_VARIABLES
2415 .iter()
2416 .filter(|k| {
2417 !full.iter().any(|w| w.as_str() == **k)
2418 && !prefixes.iter().any(|p| k.starts_with(p.as_str()))
2419 })
2420 .collect();
2421 assert!(
2422 undocumented.is_empty(),
2423 "not in configuration.md: {undocumented:?}"
2424 );
2425 let real =
2426 |name: &str| KNOWN_VARIABLES.contains(&name) || TOOLING_VARIABLES.contains(&name);
2427 let stale: Vec<_> = full
2428 .iter()
2429 .filter(|w| !real(w) && !real(w.strip_suffix("_FILE").unwrap_or(w.as_str())))
2430 .collect();
2431 assert!(stale.is_empty(), "documented, but unknown: {stale:?}");
2432 let stale_prefixes: Vec<_> = prefixes
2433 .iter()
2434 .filter(|p| {
2435 !KNOWN_VARIABLES
2436 .iter()
2437 .chain(TOOLING_VARIABLES)
2438 .any(|k| k.starts_with(p.as_str()))
2439 })
2440 .collect();
2441 assert!(
2442 stale_prefixes.is_empty(),
2443 "documented prefix matches nothing: {stale_prefixes:?}"
2444 );
2445 }
2446}