1use std::path::{Component, Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::defaults::default_true;
9
10fn default_acp_agent_name() -> String {
11 "zeph".to_owned()
12}
13
14fn default_acp_agent_version() -> String {
15 env!("CARGO_PKG_VERSION").to_owned()
16}
17
18fn default_acp_max_sessions() -> usize {
19 4
20}
21
22fn default_acp_session_idle_timeout_secs() -> u64 {
23 1800
24}
25
26fn default_acp_broadcast_capacity() -> usize {
27 256
28}
29
30fn default_acp_transport() -> AcpTransport {
31 AcpTransport::Stdio
32}
33
34fn default_acp_http_bind() -> String {
35 "127.0.0.1:9800".to_owned()
36}
37
38fn default_acp_discovery_enabled() -> bool {
39 true
40}
41
42pub const ACP_AUTH_CLIENT_ID_DEFAULT: &str = "default";
44pub const ACP_AUTH_CLIENT_ID_LOCAL: &str = "acp-local";
46
47#[derive(Clone, Deserialize, Serialize)]
55pub struct AcpAuthClient {
56 pub id: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub token: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub token_vault_key: Option<String>,
65}
66
67impl std::fmt::Debug for AcpAuthClient {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("AcpAuthClient")
70 .field("id", &self.id)
71 .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
72 .field("token_vault_key", &self.token_vault_key)
73 .finish()
74 }
75}
76
77fn default_acp_lsp_max_diagnostics_per_file() -> usize {
78 20
79}
80
81fn default_acp_lsp_max_diagnostic_files() -> usize {
82 5
83}
84
85fn default_acp_lsp_max_references() -> usize {
86 100
87}
88
89fn default_acp_lsp_max_workspace_symbols() -> usize {
90 50
91}
92
93fn default_acp_lsp_request_timeout_secs() -> u64 {
94 10
95}
96
97fn default_acp_elicitation_timeout_secs() -> u64 {
98 120
99}
100
101fn default_acp_terminal_timeout_secs() -> u64 {
102 120
103}
104
105fn default_acp_mcp_timeout_secs() -> u64 {
106 300
107}
108
109fn default_acp_notify_ack_timeout_ms() -> u64 {
110 5000
111}
112
113fn default_lsp_mcp_server_id() -> String {
114 "mcpls".into()
115}
116fn default_lsp_token_budget() -> usize {
117 2000
118}
119fn default_lsp_max_per_file() -> usize {
120 20
121}
122fn default_lsp_max_symbols() -> usize {
123 5
124}
125fn default_lsp_call_timeout_secs() -> u64 {
126 5
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
145#[serde(rename_all = "lowercase")]
146#[non_exhaustive]
147pub enum AcpAuthMethod {
148 Agent,
150}
151
152impl<'de> serde::Deserialize<'de> for AcpAuthMethod {
153 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
154 let s = String::deserialize(d)?;
155 match s.as_str() {
156 "agent" => Ok(Self::Agent),
157 other => Err(serde::de::Error::unknown_variant(other, &["agent"])),
158 }
159 }
160}
161
162impl std::fmt::Display for AcpAuthMethod {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 match self {
165 Self::Agent => f.write_str("agent"),
166 }
167 }
168}
169
170fn default_acp_auth_methods() -> Vec<AcpAuthMethod> {
171 vec![AcpAuthMethod::Agent]
172}
173
174#[derive(Debug, thiserror::Error)]
176#[non_exhaustive]
177pub enum AdditionalDirError {
178 #[error("path `{0}` contains `..` traversal")]
180 Traversal(PathBuf),
181 #[error("path `{0}` is a reserved system or credentials directory")]
183 Reserved(PathBuf),
184 #[error("failed to canonicalize `{path}`: {source}")]
186 Canonicalize {
187 path: PathBuf,
188 #[source]
189 source: std::io::Error,
190 },
191}
192
193#[derive(Clone, PartialEq, Eq)]
211pub struct AdditionalDir(PathBuf);
212
213impl AdditionalDir {
214 pub fn parse(raw: impl Into<PathBuf>) -> Result<Self, AdditionalDirError> {
220 let raw: PathBuf = raw.into();
221
222 let expanded = if raw.starts_with("~") {
224 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
225 home.join(raw.strip_prefix("~").unwrap_or(&raw))
226 } else {
227 raw.clone()
228 };
229
230 for component in expanded.components() {
232 if component == Component::ParentDir {
233 return Err(AdditionalDirError::Traversal(raw));
234 }
235 }
236
237 let canon =
238 std::fs::canonicalize(&expanded).map_err(|e| AdditionalDirError::Canonicalize {
239 path: raw.clone(),
240 source: e,
241 })?;
242
243 let reserved = reserved_prefixes();
245 for prefix in &reserved {
246 if canon.starts_with(prefix) {
247 return Err(AdditionalDirError::Reserved(canon));
248 }
249 }
250
251 Ok(Self(canon))
252 }
253
254 #[must_use]
256 pub fn as_path(&self) -> &Path {
257 &self.0
258 }
259}
260
261fn reserved_prefixes() -> Vec<PathBuf> {
262 let mut prefixes = vec![PathBuf::from("/proc"), PathBuf::from("/sys")];
263 if let Some(home) = dirs::home_dir() {
264 prefixes.push(home.join(".ssh"));
265 prefixes.push(home.join(".gnupg"));
266 prefixes.push(home.join(".aws"));
267 }
268 prefixes
269}
270
271impl std::fmt::Debug for AdditionalDir {
272 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273 write!(f, "AdditionalDir({:?})", self.0)
274 }
275}
276
277impl std::fmt::Display for AdditionalDir {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 write!(f, "{}", self.0.display())
280 }
281}
282
283impl Serialize for AdditionalDir {
284 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
285 self.0.to_string_lossy().serialize(s)
286 }
287}
288
289impl<'de> serde::Deserialize<'de> for AdditionalDir {
290 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
291 let s = String::deserialize(d)?;
292 Self::parse(s).map_err(serde::de::Error::custom)
293 }
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
312#[serde(rename_all = "lowercase")]
313#[non_exhaustive]
314pub enum ToolDensity {
315 Compact,
317 #[default]
319 Inline,
320 Block,
322}
323
324impl ToolDensity {
325 #[must_use]
339 pub fn cycle(self) -> Self {
340 match self {
341 Self::Compact => Self::Inline,
342 Self::Inline => Self::Block,
343 Self::Block => Self::Compact,
344 }
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
372#[serde(rename_all = "lowercase")]
373#[non_exhaustive]
374pub enum ColorMode {
375 #[default]
377 Auto,
378 Truecolor,
380 Ansi256,
382 Ansi16,
384 Never,
386}
387
388#[derive(Debug, Clone, Default, Deserialize, Serialize)]
407#[serde(default)]
408pub struct ThemeConfig {
409 pub name: String,
413 pub color_mode: ColorMode,
415}
416
417#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
433#[serde(rename_all = "lowercase")]
434pub enum Motion {
435 #[default]
437 Full,
438 Minimal,
440 Off,
442}
443
444#[allow(clippy::struct_excessive_bools)]
460#[derive(Debug, Clone, Deserialize, Serialize)]
461pub struct DelightsConfig {
462 #[serde(default = "default_true")]
464 pub stream_metrics: bool,
465 #[serde(default = "default_true")]
467 pub toasts: bool,
468 #[serde(default = "default_true")]
470 pub completion_flash: bool,
471 #[serde(default = "default_true")]
473 pub smooth_scroll: bool,
474 #[serde(default = "default_true")]
476 pub splash_shimmer: bool,
477}
478
479impl Default for DelightsConfig {
480 fn default() -> Self {
481 Self {
482 stream_metrics: true,
483 toasts: true,
484 completion_flash: true,
485 smooth_scroll: true,
486 splash_shimmer: true,
487 }
488 }
489}
490
491#[derive(Debug, Clone, Default, Deserialize, Serialize)]
513pub struct TuiConfig {
514 #[serde(default)]
517 pub show_source_labels: bool,
518 #[serde(default)]
523 pub tool_density: ToolDensity,
524 #[serde(default)]
528 pub motion: Motion,
529 #[serde(default)]
531 pub fleet: FleetConfig,
532 #[serde(default)]
534 pub theme: ThemeConfig,
535 #[serde(default)]
539 pub delights: DelightsConfig,
540 #[serde(default)]
545 pub mouse: bool,
546}
547
548#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
550#[serde(default)]
551pub struct FleetConfig {
552 pub refresh_interval_secs: u64,
554 pub max_sessions: u32,
556}
557
558impl Default for FleetConfig {
559 fn default() -> Self {
560 Self {
561 refresh_interval_secs: 5,
562 max_sessions: 50,
563 }
564 }
565}
566
567#[derive(Debug, Clone, Default, Deserialize, Serialize)]
569#[serde(rename_all = "lowercase")]
570#[non_exhaustive]
571pub enum AcpTransport {
572 #[default]
574 Stdio,
575 Http,
577 Both,
579}
580
581#[derive(Clone, Debug, Default, Deserialize, Serialize)]
583pub struct SubagentPresetConfig {
584 pub name: String,
586 pub command: String,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub cwd: Option<PathBuf>,
591 #[serde(default = "default_subagent_handshake_timeout_secs")]
593 pub handshake_timeout_secs: u64,
594 #[serde(default = "default_subagent_prompt_timeout_secs")]
596 pub prompt_timeout_secs: u64,
597}
598
599#[derive(Clone, Debug, Default, Deserialize, Serialize)]
612pub struct AcpSubagentsConfig {
613 #[serde(default)]
615 pub enabled: bool,
616
617 #[serde(default)]
619 pub presets: Vec<SubagentPresetConfig>,
620}
621
622fn default_subagent_handshake_timeout_secs() -> u64 {
623 30
624}
625
626fn default_subagent_prompt_timeout_secs() -> u64 {
627 600
628}
629
630#[derive(Clone, Deserialize, Serialize)]
645pub struct AcpConfig {
646 #[serde(default)]
648 pub enabled: bool,
649 #[serde(default = "default_acp_agent_name")]
651 pub agent_name: String,
652 #[serde(default = "default_acp_agent_version")]
654 pub agent_version: String,
655 #[serde(default = "default_acp_max_sessions")]
657 pub max_sessions: usize,
658 #[serde(default = "default_acp_session_idle_timeout_secs")]
660 pub session_idle_timeout_secs: u64,
661 #[serde(default = "default_acp_broadcast_capacity")]
663 pub broadcast_capacity: usize,
664 #[serde(skip_serializing_if = "Option::is_none")]
666 pub permission_file: Option<std::path::PathBuf>,
667 #[serde(default)]
670 pub available_models: Vec<String>,
671 #[serde(default = "default_acp_transport")]
673 pub transport: AcpTransport,
674 #[serde(default = "default_acp_http_bind")]
676 pub http_bind: String,
677 #[serde(skip_serializing_if = "Option::is_none")]
682 pub auth_token: Option<String>,
683 #[serde(default)]
687 pub auth_clients: Vec<AcpAuthClient>,
688 #[serde(default = "default_acp_discovery_enabled")]
691 pub discovery_enabled: bool,
692 #[serde(default)]
694 pub lsp: AcpLspConfig,
695 #[serde(default)]
707 pub additional_directories: Vec<AdditionalDir>,
708 #[serde(default = "default_acp_auth_methods")]
713 pub auth_methods: Vec<AcpAuthMethod>,
714 #[serde(default = "default_true")]
719 pub message_ids_enabled: bool,
720 #[serde(default)]
722 pub subagents: AcpSubagentsConfig,
723 #[serde(default)]
725 pub timeouts: AcpTimeoutsConfig,
726 #[serde(default)]
729 pub model_config: AcpModelConfigConfig,
730}
731
732impl Default for AcpConfig {
733 fn default() -> Self {
734 Self {
735 enabled: false,
736 agent_name: default_acp_agent_name(),
737 agent_version: default_acp_agent_version(),
738 max_sessions: default_acp_max_sessions(),
739 session_idle_timeout_secs: default_acp_session_idle_timeout_secs(),
740 broadcast_capacity: default_acp_broadcast_capacity(),
741 permission_file: None,
742 available_models: Vec::new(),
743 transport: default_acp_transport(),
744 http_bind: default_acp_http_bind(),
745 auth_token: None,
746 auth_clients: Vec::new(),
747 discovery_enabled: default_acp_discovery_enabled(),
748 lsp: AcpLspConfig::default(),
749 additional_directories: Vec::new(),
750 auth_methods: default_acp_auth_methods(),
751 message_ids_enabled: true,
752 subagents: AcpSubagentsConfig::default(),
753 timeouts: AcpTimeoutsConfig::default(),
754 model_config: AcpModelConfigConfig::default(),
755 }
756 }
757}
758
759impl std::fmt::Debug for AcpConfig {
760 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
761 f.debug_struct("AcpConfig")
762 .field("enabled", &self.enabled)
763 .field("agent_name", &self.agent_name)
764 .field("agent_version", &self.agent_version)
765 .field("max_sessions", &self.max_sessions)
766 .field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
767 .field("broadcast_capacity", &self.broadcast_capacity)
768 .field("permission_file", &self.permission_file)
769 .field("available_models", &self.available_models)
770 .field("transport", &self.transport)
771 .field("http_bind", &self.http_bind)
772 .field(
773 "auth_token",
774 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
775 )
776 .field("auth_clients", &self.auth_clients)
777 .field("discovery_enabled", &self.discovery_enabled)
778 .field("lsp", &self.lsp)
779 .field("additional_directories", &self.additional_directories)
780 .field("auth_methods", &self.auth_methods)
781 .field("message_ids_enabled", &self.message_ids_enabled)
782 .field("subagents", &self.subagents)
783 .field("timeouts", &self.timeouts)
784 .field("model_config", &self.model_config)
785 .finish()
786 }
787}
788
789impl AcpConfig {
790 pub fn validate_auth_clients(&self) -> Result<(), String> {
802 let mut seen_ids = std::collections::HashSet::new();
803 let mut seen_inline_tokens = std::collections::HashSet::new();
804
805 if let Some(ref token) = self.auth_token {
806 if token.trim().is_empty() {
807 return Err("[acp] auth_token must not be empty or whitespace-only".to_owned());
808 }
809 seen_inline_tokens.insert(token.as_str());
810 }
811
812 for client in &self.auth_clients {
813 if client.id.is_empty() {
814 return Err("[[acp.auth_clients]] entry has an empty id".to_owned());
815 }
816 if client.id == ACP_AUTH_CLIENT_ID_DEFAULT || client.id == ACP_AUTH_CLIENT_ID_LOCAL {
817 return Err(format!(
818 "[[acp.auth_clients]] id {:?} is reserved (collides with the legacy \
819 auth_token client or the unauthenticated/stdio owner bucket)",
820 client.id
821 ));
822 }
823 if client.id.contains(':') {
824 return Err(format!(
825 "[[acp.auth_clients]] id {:?} must not contain ':'",
826 client.id
827 ));
828 }
829 if !seen_ids.insert(client.id.as_str()) {
830 return Err(format!(
831 "[[acp.auth_clients]] id {:?} is duplicated",
832 client.id
833 ));
834 }
835 match (&client.token, &client.token_vault_key) {
836 (Some(_), Some(_)) => {
837 return Err(format!(
838 "[[acp.auth_clients]] id {:?} sets both token and token_vault_key; \
839 exactly one must be set",
840 client.id
841 ));
842 }
843 (None, None) => {
844 return Err(format!(
845 "[[acp.auth_clients]] id {:?} sets neither token nor token_vault_key; \
846 exactly one must be set",
847 client.id
848 ));
849 }
850 (Some(token), None) => {
851 if token.trim().is_empty() {
852 return Err(format!(
853 "[[acp.auth_clients]] id {:?} has an empty or whitespace-only token",
854 client.id
855 ));
856 }
857 if !seen_inline_tokens.insert(token.as_str()) {
858 return Err(format!(
859 "[[acp.auth_clients]] id {:?} has a token that collides with \
860 another configured client's inline token",
861 client.id
862 ));
863 }
864 }
865 (None, Some(_)) => {}
866 }
867 }
868
869 Ok(())
870 }
871}
872
873#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
879#[serde(rename_all = "snake_case")]
880pub enum AcpTemperaturePreset {
881 Precise,
883 #[default]
885 Balanced,
886 Creative,
888}
889
890impl AcpTemperaturePreset {
891 #[must_use]
893 pub fn temperature(self) -> f64 {
894 match self {
895 Self::Precise => 0.2,
896 Self::Balanced => 0.7,
897 Self::Creative => 1.0,
898 }
899 }
900
901 #[must_use]
903 pub fn as_str(self) -> &'static str {
904 match self {
905 Self::Precise => "precise",
906 Self::Balanced => "balanced",
907 Self::Creative => "creative",
908 }
909 }
910}
911
912impl std::str::FromStr for AcpTemperaturePreset {
913 type Err = ();
914
915 fn from_str(s: &str) -> Result<Self, Self::Err> {
916 match s {
917 "precise" => Ok(Self::Precise),
918 "balanced" => Ok(Self::Balanced),
919 "creative" => Ok(Self::Creative),
920 _ => Err(()),
921 }
922 }
923}
924
925#[derive(Debug, Clone, Default, Deserialize, Serialize)]
938pub struct AcpModelConfigConfig {
939 #[serde(default)]
941 pub default_temperature_preset: AcpTemperaturePreset,
942}
943
944#[derive(Debug, Clone, Deserialize, Serialize)]
949pub struct AcpTimeoutsConfig {
950 #[serde(default = "default_acp_elicitation_timeout_secs")]
952 pub elicitation_secs: u64,
953 #[serde(default = "default_acp_terminal_timeout_secs")]
955 pub terminal_secs: u64,
956 #[serde(default = "default_acp_mcp_timeout_secs")]
958 pub mcp_secs: u64,
959 #[serde(default = "default_acp_notify_ack_timeout_ms")]
964 pub notify_ack_timeout_ms: u64,
965}
966
967impl Default for AcpTimeoutsConfig {
968 fn default() -> Self {
969 Self {
970 elicitation_secs: default_acp_elicitation_timeout_secs(),
971 terminal_secs: default_acp_terminal_timeout_secs(),
972 mcp_secs: default_acp_mcp_timeout_secs(),
973 notify_ack_timeout_ms: default_acp_notify_ack_timeout_ms(),
974 }
975 }
976}
977
978#[derive(Debug, Clone, Deserialize, Serialize)]
983pub struct AcpLspConfig {
984 #[serde(default = "default_true")]
986 pub enabled: bool,
987 #[serde(default = "default_true")]
989 pub auto_diagnostics_on_save: bool,
990 #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
992 pub max_diagnostics_per_file: usize,
993 #[serde(default = "default_acp_lsp_max_diagnostic_files")]
995 pub max_diagnostic_files: usize,
996 #[serde(default = "default_acp_lsp_max_references")]
998 pub max_references: usize,
999 #[serde(default = "default_acp_lsp_max_workspace_symbols")]
1001 pub max_workspace_symbols: usize,
1002 #[serde(default = "default_acp_lsp_request_timeout_secs")]
1004 pub request_timeout_secs: u64,
1005}
1006
1007impl Default for AcpLspConfig {
1008 fn default() -> Self {
1009 Self {
1010 enabled: true,
1011 auto_diagnostics_on_save: true,
1012 max_diagnostics_per_file: default_acp_lsp_max_diagnostics_per_file(),
1013 max_diagnostic_files: default_acp_lsp_max_diagnostic_files(),
1014 max_references: default_acp_lsp_max_references(),
1015 max_workspace_symbols: default_acp_lsp_max_workspace_symbols(),
1016 request_timeout_secs: default_acp_lsp_request_timeout_secs(),
1017 }
1018 }
1019}
1020
1021#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1025#[serde(rename_all = "lowercase")]
1026#[non_exhaustive]
1027pub enum DiagnosticSeverity {
1028 #[default]
1029 Error,
1030 Warning,
1031 Info,
1032 Hint,
1033}
1034
1035#[derive(Debug, Clone, Deserialize, Serialize)]
1039#[serde(default)]
1040pub struct DiagnosticsConfig {
1041 pub enabled: bool,
1043 #[serde(default = "default_lsp_max_per_file")]
1045 pub max_per_file: usize,
1046 #[serde(default)]
1048 pub min_severity: DiagnosticSeverity,
1049}
1050impl Default for DiagnosticsConfig {
1051 fn default() -> Self {
1052 Self {
1053 enabled: true,
1054 max_per_file: default_lsp_max_per_file(),
1055 min_severity: DiagnosticSeverity::default(),
1056 }
1057 }
1058}
1059
1060#[derive(Debug, Clone, Deserialize, Serialize)]
1062#[serde(default)]
1063pub struct HoverConfig {
1064 pub enabled: bool,
1066 #[serde(default = "default_lsp_max_symbols")]
1068 pub max_symbols: usize,
1069}
1070impl Default for HoverConfig {
1071 fn default() -> Self {
1072 Self {
1073 enabled: false,
1074 max_symbols: default_lsp_max_symbols(),
1075 }
1076 }
1077}
1078
1079#[derive(Debug, Clone, Deserialize, Serialize)]
1081#[serde(default)]
1082pub struct LspConfig {
1083 pub enabled: bool,
1085 #[serde(default = "default_lsp_mcp_server_id")]
1087 pub mcp_server_id: String,
1088 #[serde(default = "default_lsp_token_budget")]
1090 pub token_budget: usize,
1091 #[serde(default = "default_lsp_call_timeout_secs")]
1093 pub call_timeout_secs: u64,
1094 #[serde(default)]
1096 pub diagnostics: DiagnosticsConfig,
1097 #[serde(default)]
1099 pub hover: HoverConfig,
1100}
1101impl Default for LspConfig {
1102 fn default() -> Self {
1103 Self {
1104 enabled: false,
1105 mcp_server_id: default_lsp_mcp_server_id(),
1106 token_budget: default_lsp_token_budget(),
1107 call_timeout_secs: default_lsp_call_timeout_secs(),
1108 diagnostics: DiagnosticsConfig::default(),
1109 hover: HoverConfig::default(),
1110 }
1111 }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117
1118 #[test]
1119 fn acp_auth_method_unknown_variant_fails() {
1120 assert!(serde_json::from_str::<AcpAuthMethod>(r#""bearer""#).is_err());
1121 assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
1122 assert!(serde_json::from_str::<AcpAuthMethod>(r#""Agent""#).is_err());
1123 }
1124
1125 #[test]
1126 fn acp_auth_method_known_variant_succeeds() {
1127 let m = serde_json::from_str::<AcpAuthMethod>(r#""agent""#).unwrap();
1128 assert_eq!(m, AcpAuthMethod::Agent);
1129 }
1130
1131 fn client(id: &str, token: &str) -> AcpAuthClient {
1134 AcpAuthClient {
1135 id: id.to_owned(),
1136 token: Some(token.to_owned()),
1137 token_vault_key: None,
1138 }
1139 }
1140
1141 #[test]
1142 fn validate_auth_clients_empty_config_ok() {
1143 assert!(AcpConfig::default().validate_auth_clients().is_ok());
1144 }
1145
1146 #[test]
1147 fn validate_auth_clients_legacy_auth_token_only_ok() {
1148 let cfg = AcpConfig {
1149 auth_token: Some("secret".to_owned()),
1150 ..AcpConfig::default()
1151 };
1152 assert!(cfg.validate_auth_clients().is_ok());
1153 }
1154
1155 #[test]
1156 fn validate_auth_clients_single_client_ok() {
1157 let cfg = AcpConfig {
1158 auth_clients: vec![client("alice", "token-a")],
1159 ..AcpConfig::default()
1160 };
1161 assert!(cfg.validate_auth_clients().is_ok());
1162 }
1163
1164 #[test]
1165 fn validate_auth_clients_coexist_with_legacy_ok() {
1166 let cfg = AcpConfig {
1167 auth_token: Some("legacy".to_owned()),
1168 auth_clients: vec![client("alice", "token-a"), client("bob", "token-b")],
1169 ..AcpConfig::default()
1170 };
1171 assert!(cfg.validate_auth_clients().is_ok());
1172 }
1173
1174 #[test]
1175 fn validate_auth_clients_rejects_reserved_id_default() {
1176 let cfg = AcpConfig {
1177 auth_clients: vec![client(ACP_AUTH_CLIENT_ID_DEFAULT, "token-a")],
1178 ..AcpConfig::default()
1179 };
1180 let err = cfg.validate_auth_clients().unwrap_err();
1181 assert!(err.contains("reserved"), "unexpected error: {err}");
1182 }
1183
1184 #[test]
1185 fn validate_auth_clients_rejects_reserved_id_acp_local() {
1186 let cfg = AcpConfig {
1187 auth_clients: vec![client(ACP_AUTH_CLIENT_ID_LOCAL, "token-a")],
1188 ..AcpConfig::default()
1189 };
1190 let err = cfg.validate_auth_clients().unwrap_err();
1191 assert!(err.contains("reserved"), "unexpected error: {err}");
1192 }
1193
1194 #[test]
1195 fn validate_auth_clients_rejects_duplicate_id() {
1196 let cfg = AcpConfig {
1197 auth_clients: vec![client("alice", "token-a"), client("alice", "token-b")],
1198 ..AcpConfig::default()
1199 };
1200 let err = cfg.validate_auth_clients().unwrap_err();
1201 assert!(err.contains("duplicated"), "unexpected error: {err}");
1202 }
1203
1204 #[test]
1205 fn validate_auth_clients_rejects_id_containing_colon() {
1206 let cfg = AcpConfig {
1207 auth_clients: vec![client("alice:2", "token-a")],
1208 ..AcpConfig::default()
1209 };
1210 let err = cfg.validate_auth_clients().unwrap_err();
1211 assert!(err.contains("':'"), "unexpected error: {err}");
1212 }
1213
1214 #[test]
1215 fn validate_auth_clients_rejects_empty_id() {
1216 let cfg = AcpConfig {
1217 auth_clients: vec![client("", "token-a")],
1218 ..AcpConfig::default()
1219 };
1220 let err = cfg.validate_auth_clients().unwrap_err();
1221 assert!(err.contains("empty id"), "unexpected error: {err}");
1222 }
1223
1224 #[test]
1225 fn validate_auth_clients_rejects_neither_token_nor_vault_key() {
1226 let cfg = AcpConfig {
1227 auth_clients: vec![AcpAuthClient {
1228 id: "alice".to_owned(),
1229 token: None,
1230 token_vault_key: None,
1231 }],
1232 ..AcpConfig::default()
1233 };
1234 let err = cfg.validate_auth_clients().unwrap_err();
1235 assert!(err.contains("neither"), "unexpected error: {err}");
1236 }
1237
1238 #[test]
1239 fn validate_auth_clients_rejects_both_token_and_vault_key() {
1240 let cfg = AcpConfig {
1241 auth_clients: vec![AcpAuthClient {
1242 id: "alice".to_owned(),
1243 token: Some("token-a".to_owned()),
1244 token_vault_key: Some("ZEPH_ACP_TOKEN_ALICE".to_owned()),
1245 }],
1246 ..AcpConfig::default()
1247 };
1248 let err = cfg.validate_auth_clients().unwrap_err();
1249 assert!(err.contains("both"), "unexpected error: {err}");
1250 }
1251
1252 #[test]
1253 fn validate_auth_clients_vault_key_only_ok() {
1254 let cfg = AcpConfig {
1255 auth_clients: vec![AcpAuthClient {
1256 id: "alice".to_owned(),
1257 token: None,
1258 token_vault_key: Some("ZEPH_ACP_TOKEN_ALICE".to_owned()),
1259 }],
1260 ..AcpConfig::default()
1261 };
1262 assert!(cfg.validate_auth_clients().is_ok());
1263 }
1264
1265 #[test]
1266 fn validate_auth_clients_rejects_duplicate_inline_tokens_across_clients() {
1267 let cfg = AcpConfig {
1268 auth_clients: vec![client("alice", "shared"), client("bob", "shared")],
1269 ..AcpConfig::default()
1270 };
1271 let err = cfg.validate_auth_clients().unwrap_err();
1272 assert!(err.contains("collides"), "unexpected error: {err}");
1273 }
1274
1275 #[test]
1276 fn validate_auth_clients_rejects_inline_token_colliding_with_legacy_default_token() {
1277 let cfg = AcpConfig {
1278 auth_token: Some("shared".to_owned()),
1279 auth_clients: vec![client("alice", "shared")],
1280 ..AcpConfig::default()
1281 };
1282 let err = cfg.validate_auth_clients().unwrap_err();
1283 assert!(err.contains("collides"), "unexpected error: {err}");
1284 }
1285
1286 #[test]
1287 fn validate_auth_clients_rejects_empty_legacy_auth_token() {
1288 let cfg = AcpConfig {
1289 auth_token: Some(String::new()),
1290 ..AcpConfig::default()
1291 };
1292 let err = cfg.validate_auth_clients().unwrap_err();
1293 assert!(err.contains("empty"), "unexpected error: {err}");
1294 }
1295
1296 #[test]
1297 fn validate_auth_clients_rejects_whitespace_only_legacy_auth_token() {
1298 let cfg = AcpConfig {
1299 auth_token: Some(" ".to_owned()),
1300 ..AcpConfig::default()
1301 };
1302 let err = cfg.validate_auth_clients().unwrap_err();
1303 assert!(err.contains("empty"), "unexpected error: {err}");
1304 }
1305
1306 #[test]
1307 fn validate_auth_clients_rejects_empty_inline_client_token() {
1308 let cfg = AcpConfig {
1309 auth_clients: vec![client("alice", "")],
1310 ..AcpConfig::default()
1311 };
1312 let err = cfg.validate_auth_clients().unwrap_err();
1313 assert!(err.contains("empty"), "unexpected error: {err}");
1314 }
1315
1316 #[test]
1317 fn validate_auth_clients_rejects_whitespace_only_inline_client_token() {
1318 let cfg = AcpConfig {
1319 auth_clients: vec![client("alice", " ")],
1320 ..AcpConfig::default()
1321 };
1322 let err = cfg.validate_auth_clients().unwrap_err();
1323 assert!(err.contains("empty"), "unexpected error: {err}");
1324 }
1325
1326 #[test]
1327 fn additional_dir_rejects_dotdot_traversal() {
1328 let result = AdditionalDir::parse(std::path::PathBuf::from("/tmp/../etc"));
1329 assert!(
1330 matches!(result, Err(AdditionalDirError::Traversal(_))),
1331 "expected Traversal, got {result:?}"
1332 );
1333 }
1334
1335 #[test]
1336 fn additional_dir_rejects_proc() {
1337 if !std::path::Path::new("/proc").exists() {
1339 return;
1340 }
1341 let result = AdditionalDir::parse(std::path::PathBuf::from("/proc/self"));
1342 assert!(
1343 matches!(result, Err(AdditionalDirError::Reserved(_))),
1344 "expected Reserved, got {result:?}"
1345 );
1346 }
1347
1348 #[test]
1349 fn additional_dir_rejects_ssh() {
1350 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_owned());
1351 let ssh = std::path::PathBuf::from(format!("{home}/.ssh"));
1352 if !ssh.exists() {
1353 return;
1354 }
1355 let result = AdditionalDir::parse(ssh.clone());
1356 assert!(
1357 matches!(result, Err(AdditionalDirError::Reserved(_))),
1358 "expected Reserved for {ssh:?}, got {result:?}"
1359 );
1360 }
1361
1362 #[test]
1363 fn additional_dir_accepts_tmp() {
1364 let tmp = std::env::temp_dir();
1365 match AdditionalDir::parse(tmp.clone()) {
1367 Ok(dir) => {
1368 assert!(dir.as_path().is_absolute());
1370 }
1371 Err(AdditionalDirError::Canonicalize { .. }) => {
1372 }
1374 Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
1375 }
1376 }
1377}