1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fs,
4 path::{Component, Path, PathBuf},
5};
6
7#[cfg(unix)]
8use std::os::unix::fs::PermissionsExt;
9
10use serde::{Deserialize, Serialize};
11
12use gloves_core::error::{GlovesError, Result};
13use gloves_core::types::{AgentId, SecretId};
14
15const CONFIG_VERSION_V1: u32 = 1;
16const CONFIG_VERSION_V2: u32 = 2;
17const DEFAULT_ROOT: &str = ".openclaw/secrets";
18const DEFAULT_DAEMON_BIND: &str = "127.0.0.1:7788";
19const DEFAULT_DAEMON_IO_TIMEOUT_SECONDS: u64 = 5;
20const DEFAULT_DAEMON_REQUEST_LIMIT_BYTES: usize = 16 * 1024;
21const DEFAULT_AGENT_ID: &str = "default-agent";
22pub const DEFAULT_SECRET_TTL_DAYS: i64 = 30;
24const DEFAULT_VAULT_MOUNT_TTL: &str = "1h";
25const DEFAULT_VAULT_SECRET_TTL_DAYS: i64 = 365;
26const DEFAULT_VAULT_SECRET_LENGTH_BYTES: usize = 64;
27const URL_SCHEME_HTTP_PREFIX: &str = "http://";
28const URL_SCHEME_HTTPS_PREFIX: &str = "https://";
29
30pub const CONFIG_FILE_NAME: &str = ".gloves.toml";
32pub const CONFIG_SCHEMA_VERSION: u32 = CONFIG_VERSION_V2;
34
35#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37pub enum ConfigSource {
38 Flag,
40 Env,
42 Discovered,
44 None,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct ConfigSelection {
51 pub source: ConfigSource,
53 pub path: Option<PathBuf>,
55}
56
57#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
59#[serde(rename_all = "lowercase")]
60pub enum PathOperation {
61 Read,
63 Write,
65 List,
67 Mount,
69}
70
71#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
73#[serde(rename_all = "lowercase")]
74pub enum SecretAclOperation {
75 Read,
77 Write,
79 List,
81 Revoke,
83 Request,
85 Status,
87 Approve,
89 Deny,
91}
92
93#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "lowercase")]
96pub enum VaultMode {
97 Auto,
99 Required,
101 Disabled,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(deny_unknown_fields)]
108pub struct GlovesConfigFile {
109 pub version: u32,
111 #[serde(default)]
113 pub paths: ConfigPathsFile,
114 #[serde(default)]
116 pub private_paths: BTreeMap<String, String>,
117 #[serde(default)]
119 pub daemon: DaemonConfigFile,
120 #[serde(default)]
122 pub vault: VaultConfigFile,
123 #[serde(default)]
125 pub defaults: DefaultsConfigFile,
126 #[serde(default)]
128 pub integrations: BTreeMap<String, IntegrationConfigFile>,
129 #[serde(default)]
131 pub agents: BTreeMap<String, AgentAccessFile>,
132 #[serde(default)]
134 pub secrets: SecretsConfigFile,
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(deny_unknown_fields)]
140pub struct ConfigPathsFile {
141 pub root: Option<String>,
143}
144
145#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(deny_unknown_fields)]
148pub struct DaemonConfigFile {
149 pub bind: Option<String>,
151 pub io_timeout_seconds: Option<u64>,
153 pub request_limit_bytes: Option<usize>,
155}
156
157#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(deny_unknown_fields)]
160pub struct VaultConfigFile {
161 pub mode: Option<VaultMode>,
163 #[serde(default)]
165 pub mounts: BTreeMap<String, String>,
166}
167
168#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
170#[serde(deny_unknown_fields)]
171pub struct DefaultsConfigFile {
172 pub agent_id: Option<String>,
174 pub secret_ttl_days: Option<i64>,
176 pub vault_mount_ttl: Option<String>,
178 pub vault_secret_ttl_days: Option<i64>,
180 pub vault_secret_length_bytes: Option<usize>,
182}
183
184#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
186#[serde(deny_unknown_fields)]
187pub struct SecretsConfigFile {
188 #[serde(default)]
190 pub acl: BTreeMap<String, SecretAccessFile>,
191 #[serde(default)]
193 pub pipe: SecretPipePoliciesFile,
194}
195
196#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
198#[serde(deny_unknown_fields)]
199pub struct SecretAccessFile {
200 #[serde(default, alias = "paths")]
202 pub refs: Vec<String>,
203 #[serde(default)]
205 pub operations: Vec<SecretAclOperation>,
206}
207
208#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
210#[serde(deny_unknown_fields)]
211pub struct SecretPipePoliciesFile {
212 #[serde(default)]
214 pub commands: BTreeMap<String, SecretPipeCommandPolicyFile>,
215}
216
217#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
219#[serde(deny_unknown_fields)]
220pub struct SecretPipeCommandPolicyFile {
221 #[serde(default)]
223 pub require_url: bool,
224 #[serde(default)]
226 pub url_prefixes: Vec<String>,
227}
228
229#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
231#[serde(deny_unknown_fields)]
232pub struct AgentAccessFile {
233 #[serde(default)]
235 pub paths: Vec<String>,
236 #[serde(default)]
238 pub operations: Vec<PathOperation>,
239 #[serde(default)]
241 pub secrets: Option<AgentSecretsAccessFile>,
242 #[serde(default)]
244 pub vault: Option<AgentVaultAccessFile>,
245}
246
247#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
249#[serde(deny_unknown_fields)]
250pub struct AgentSecretsAccessFile {
251 #[serde(default, alias = "paths")]
253 pub refs: Vec<String>,
254 #[serde(default)]
256 pub operations: Vec<SecretAclOperation>,
257}
258
259#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
261#[serde(deny_unknown_fields)]
262pub struct AgentVaultAccessFile {
263 #[serde(default)]
265 pub mounts: Vec<String>,
266 #[serde(default)]
268 pub operations: Vec<PathOperation>,
269}
270
271#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
273#[serde(deny_unknown_fields)]
274pub struct IntegrationConfigFile {
275 pub agent: Option<String>,
277 #[serde(default)]
279 pub profiles: Vec<String>,
280 #[serde(default)]
282 pub slots: Vec<String>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
287pub struct DaemonBootstrapConfig {
288 pub bind: String,
290 pub io_timeout_seconds: u64,
292 pub request_limit_bytes: usize,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
298pub struct VaultBootstrapConfig {
299 pub mode: VaultMode,
301 pub mounts: BTreeMap<String, PathBuf>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
307pub struct DefaultBootstrapConfig {
308 pub agent_id: AgentId,
310 pub secret_ttl_days: i64,
312 pub vault_mount_ttl: String,
314 pub vault_secret_ttl_days: i64,
316 pub vault_secret_length_bytes: usize,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
322pub struct AgentAccessPolicy {
323 pub path_aliases: Vec<String>,
325 pub operations: Vec<PathOperation>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
331pub struct SecretAccessPolicy {
332 pub refs: Vec<String>,
334 pub operations: Vec<SecretAclOperation>,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
340pub struct AgentVaultAccessPolicy {
341 pub mount_names: Vec<String>,
343 pub operations: Vec<PathOperation>,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
349pub struct IntegrationConfig {
350 pub name: String,
352 pub agent: AgentId,
354 pub profiles: Vec<String>,
356 pub slots: Vec<String>,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362pub struct SecretPipeCommandPolicy {
363 pub require_url: bool,
365 pub url_prefixes: Vec<String>,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
371pub struct GlovesConfig {
372 pub source_path: PathBuf,
374 pub root: PathBuf,
376 pub private_paths: BTreeMap<String, PathBuf>,
378 pub daemon: DaemonBootstrapConfig,
380 pub vault: VaultBootstrapConfig,
382 pub defaults: DefaultBootstrapConfig,
384 pub agents: BTreeMap<String, AgentAccessPolicy>,
386 pub secret_access: BTreeMap<String, SecretAccessPolicy>,
388 pub agent_vault_access: BTreeMap<String, AgentVaultAccessPolicy>,
390 pub integrations: BTreeMap<String, IntegrationConfig>,
392 pub secret_pipe_commands: BTreeMap<String, SecretPipeCommandPolicy>,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398pub struct ResolvedAgentPathAccess {
399 pub alias: String,
401 pub path: PathBuf,
403 pub operations: Vec<PathOperation>,
405}
406
407impl GlovesConfig {
408 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
410 let cwd = std::env::current_dir()?;
411 let absolute_path = absolutize_path(path.as_ref(), &cwd);
412 if !absolute_path.exists() {
413 return Err(GlovesError::InvalidInput(format!(
414 "config file does not exist: {}",
415 absolute_path.display()
416 )));
417 }
418
419 let raw = fs::read_to_string(&absolute_path)?;
420 let parsed = toml::from_str::<GlovesConfigFile>(&raw)
421 .map_err(|error| GlovesError::InvalidInput(format!("invalid config TOML: {error}")))?;
422 validate_config_file_permissions(&absolute_path, !parsed.private_paths.is_empty())?;
423 build_config(parsed, &absolute_path)
424 }
425
426 pub fn parse_from_str(raw: &str, source_path: impl AsRef<Path>) -> Result<Self> {
428 let parsed = toml::from_str::<GlovesConfigFile>(raw)
429 .map_err(|error| GlovesError::InvalidInput(format!("invalid config TOML: {error}")))?;
430 build_config(parsed, source_path.as_ref())
431 }
432
433 pub fn agent_paths(&self, agent: &AgentId) -> Result<Vec<ResolvedAgentPathAccess>> {
435 let policy = self
436 .agents
437 .get(agent.as_str())
438 .ok_or(GlovesError::NotFound)?;
439
440 let mut entries = Vec::with_capacity(policy.path_aliases.len());
441 for alias in &policy.path_aliases {
442 let path = self.private_paths.get(alias).ok_or_else(|| {
443 GlovesError::InvalidInput(format!(
444 "agent '{}' references unknown private path alias '{}'",
445 agent.as_str(),
446 alias
447 ))
448 })?;
449 entries.push(ResolvedAgentPathAccess {
450 alias: alias.clone(),
451 path: path.clone(),
452 operations: policy.operations.clone(),
453 });
454 }
455 Ok(entries)
456 }
457
458 pub fn has_secret_acl(&self) -> bool {
460 !self.secret_access.is_empty()
461 }
462
463 pub fn secret_access_policy(&self, agent: &AgentId) -> Option<&SecretAccessPolicy> {
465 self.secret_access.get(agent.as_str())
466 }
467
468 pub fn agent_vault_access_policy(&self, agent: &AgentId) -> Option<&AgentVaultAccessPolicy> {
470 self.agent_vault_access.get(agent.as_str())
471 }
472
473 pub fn vault_mount_path(&self, mount_name: &str) -> Option<&PathBuf> {
475 self.vault.mounts.get(mount_name)
476 }
477
478 pub fn integration(&self, name: &str) -> Option<&IntegrationConfig> {
480 self.integrations.get(name)
481 }
482
483 pub fn inferred_integration_refs(&self, name: &str) -> Result<Vec<String>> {
485 let integration = self.integrations.get(name).ok_or(GlovesError::NotFound)?;
486 let profiles = if integration.profiles.is_empty() {
487 vec!["default".to_owned()]
488 } else {
489 integration.profiles.clone()
490 };
491 if integration.slots.is_empty() {
492 return Ok(Vec::new());
493 }
494
495 let mut refs = Vec::with_capacity(profiles.len() * integration.slots.len());
496 for profile in profiles {
497 for slot in &integration.slots {
498 refs.push(format!("{name}/{profile}/{slot}"));
499 }
500 }
501 Ok(refs)
502 }
503
504 pub fn secret_pipe_command_policy(&self, command: &str) -> Option<&SecretPipeCommandPolicy> {
506 self.secret_pipe_commands.get(command)
507 }
508}
509
510impl SecretAccessPolicy {
511 pub fn allows_operation(&self, operation: SecretAclOperation) -> bool {
513 self.operations.contains(&operation)
514 }
515
516 pub fn allows_secret(&self, secret_name: &str) -> bool {
518 self.refs
519 .iter()
520 .any(|pattern| secret_pattern_matches(pattern, secret_name))
521 }
522}
523
524pub fn resolve_config_path(
526 explicit_path: Option<&Path>,
527 env_path: Option<&str>,
528 no_config: bool,
529 cwd: impl AsRef<Path>,
530) -> Result<ConfigSelection> {
531 if no_config {
532 return Ok(ConfigSelection {
533 source: ConfigSource::None,
534 path: None,
535 });
536 }
537
538 let cwd = cwd.as_ref();
539 if let Some(path) = explicit_path {
540 let candidate = absolutize_path(path, cwd);
541 if !is_regular_config_candidate(&candidate) {
542 return Err(GlovesError::InvalidInput(format!(
543 "config file must be a regular file: {}",
544 candidate.display()
545 )));
546 }
547 return Ok(ConfigSelection {
548 source: ConfigSource::Flag,
549 path: Some(candidate),
550 });
551 }
552
553 if let Some(value) = env_path {
554 if value.trim().is_empty() {
555 return Err(GlovesError::InvalidInput(
556 "GLOVES_CONFIG cannot be empty".to_owned(),
557 ));
558 }
559
560 let candidate = absolutize_path(Path::new(value), cwd);
561 if !is_regular_config_candidate(&candidate) {
562 return Err(GlovesError::InvalidInput(format!(
563 "config file must be a regular file: {}",
564 candidate.display()
565 )));
566 }
567 return Ok(ConfigSelection {
568 source: ConfigSource::Env,
569 path: Some(candidate),
570 });
571 }
572
573 if let Some(discovered) = discover_config(cwd) {
574 return Ok(ConfigSelection {
575 source: ConfigSource::Discovered,
576 path: Some(discovered),
577 });
578 }
579
580 Ok(ConfigSelection {
581 source: ConfigSource::None,
582 path: None,
583 })
584}
585
586pub fn discover_config(start_dir: impl AsRef<Path>) -> Option<PathBuf> {
588 let mut current = start_dir.as_ref();
589 loop {
590 let candidate = current.join(CONFIG_FILE_NAME);
591 if is_regular_config_candidate(&candidate) {
592 return Some(candidate);
593 }
594
595 let parent = current.parent()?;
596 current = parent;
597 }
598}
599
600fn build_config(raw: GlovesConfigFile, source_path: &Path) -> Result<GlovesConfig> {
601 validate_raw_config(&raw)?;
602
603 let source_path = absolutize_path(source_path, &std::env::current_dir()?);
604 let source_dir = source_path.parent().unwrap_or(Path::new("."));
605
606 let root_literal = raw.paths.root.as_deref().unwrap_or(DEFAULT_ROOT).to_owned();
607 let root = resolve_path_value(&root_literal, source_dir)?;
608
609 let mut private_paths = BTreeMap::new();
610 for (alias, value) in &raw.private_paths {
611 validate_alias(alias)?;
612 let resolved = resolve_path_value(value, source_dir)?;
613 private_paths.insert(alias.clone(), resolved);
614 }
615
616 let daemon = resolve_daemon_config(&raw.daemon)?;
617 let vault = resolve_vault_config(&raw.vault, source_dir)?;
618 let defaults = resolve_default_config(&raw.defaults)?;
619
620 let mut agents = BTreeMap::new();
621 let mut agent_vault_access = BTreeMap::new();
622 for (agent_name, policy) in &raw.agents {
623 AgentId::new(agent_name)?;
624 validate_agent_policy(agent_name, policy, &private_paths, &vault.mounts)?;
625 if !policy.paths.is_empty() || !policy.operations.is_empty() {
626 agents.insert(
627 agent_name.clone(),
628 AgentAccessPolicy {
629 path_aliases: policy.paths.clone(),
630 operations: policy.operations.clone(),
631 },
632 );
633 }
634 if let Some(vault_policy) = policy.vault.as_ref() {
635 agent_vault_access.insert(
636 agent_name.clone(),
637 AgentVaultAccessPolicy {
638 mount_names: vault_policy.mounts.clone(),
639 operations: vault_policy.operations.clone(),
640 },
641 );
642 }
643 }
644
645 let mut secret_access: BTreeMap<String, SecretAccessPolicy> = BTreeMap::new();
646 for (agent_name, policy) in &raw.secrets.acl {
647 AgentId::new(agent_name)?;
648 validate_secret_access_policy(agent_name, policy)?;
649 merge_secret_access_policy(
650 &mut secret_access,
651 agent_name,
652 SecretAccessPolicy {
653 refs: policy.refs.clone(),
654 operations: policy.operations.clone(),
655 },
656 );
657 }
658 for (agent_name, policy) in &raw.agents {
659 if let Some(secret_policy) = policy.secrets.as_ref() {
660 merge_secret_access_policy(
661 &mut secret_access,
662 agent_name,
663 SecretAccessPolicy {
664 refs: secret_policy.refs.clone(),
665 operations: secret_policy.operations.clone(),
666 },
667 );
668 }
669 }
670
671 let mut integrations = BTreeMap::new();
672 for (name, integration) in &raw.integrations {
673 validate_integration_config(name, integration)?;
674 let agent_literal = integration.agent.as_deref().ok_or_else(|| {
675 GlovesError::InvalidInput(format!("integration '{name}' must declare an owning agent"))
676 })?;
677 integrations.insert(
678 name.clone(),
679 IntegrationConfig {
680 name: name.clone(),
681 agent: AgentId::new(agent_literal)?,
682 profiles: normalized_integration_segments(&integration.profiles, "profiles", name)?,
683 slots: normalized_integration_segments(&integration.slots, "slots", name)?,
684 },
685 );
686 }
687
688 let mut secret_pipe_commands = BTreeMap::new();
689 for (command, policy) in &raw.secrets.pipe.commands {
690 validate_secret_pipe_command_policy(command, policy)?;
691 secret_pipe_commands.insert(
692 command.clone(),
693 SecretPipeCommandPolicy {
694 require_url: policy.require_url,
695 url_prefixes: policy.url_prefixes.clone(),
696 },
697 );
698 }
699
700 Ok(GlovesConfig {
701 source_path,
702 root,
703 private_paths,
704 daemon,
705 vault,
706 defaults,
707 agents,
708 secret_access,
709 agent_vault_access,
710 integrations,
711 secret_pipe_commands,
712 })
713}
714
715fn validate_raw_config(config: &GlovesConfigFile) -> Result<()> {
716 if !matches!(config.version, CONFIG_VERSION_V1 | CONFIG_VERSION_V2) {
717 return Err(GlovesError::InvalidInput(format!(
718 "unsupported config version {} (expected {} or {})",
719 config.version, CONFIG_VERSION_V1, CONFIG_VERSION_V2
720 )));
721 }
722
723 if let Some(root) = config.paths.root.as_ref() {
724 validate_path_literal(root, "paths.root")?;
725 }
726
727 for (alias, value) in &config.private_paths {
728 validate_alias(alias)?;
729 validate_path_literal(value, &format!("private_paths.{alias}"))?;
730 }
731
732 let _ = resolve_daemon_config(&config.daemon)?;
733 let _ = resolve_vault_config(&config.vault, Path::new("."))?;
734 let _ = resolve_default_config(&config.defaults)?;
735
736 for (agent_name, policy) in &config.secrets.acl {
737 AgentId::new(agent_name)?;
738 validate_secret_access_policy(agent_name, policy)?;
739 }
740 for (agent_name, policy) in &config.agents {
741 AgentId::new(agent_name)?;
742 validate_agent_policy(
743 agent_name,
744 policy,
745 &BTreeMap::<String, PathBuf>::new(),
746 &config.vault.mounts,
747 )?;
748 }
749 for (name, integration) in &config.integrations {
750 validate_integration_config(name, integration)?;
751 }
752 for (command, policy) in &config.secrets.pipe.commands {
753 validate_secret_pipe_command_policy(command, policy)?;
754 }
755
756 Ok(())
757}
758
759fn resolve_vault_config(raw: &VaultConfigFile, source_dir: &Path) -> Result<VaultBootstrapConfig> {
760 let mut mounts = BTreeMap::new();
761 for (mount_name, mount_path_literal) in &raw.mounts {
762 validate_alias(mount_name)?;
763 validate_path_literal(mount_path_literal, &format!("vault.mounts.{mount_name}"))?;
764 mounts.insert(
765 mount_name.clone(),
766 resolve_path_value(mount_path_literal, source_dir)?,
767 );
768 }
769
770 Ok(VaultBootstrapConfig {
771 mode: raw.mode.unwrap_or(VaultMode::Auto),
772 mounts,
773 })
774}
775
776fn resolve_daemon_config(raw: &DaemonConfigFile) -> Result<DaemonBootstrapConfig> {
777 let bind = raw
778 .bind
779 .clone()
780 .unwrap_or_else(|| DEFAULT_DAEMON_BIND.to_owned());
781 let bind_addr = bind.parse::<std::net::SocketAddr>().map_err(|error| {
782 GlovesError::InvalidInput(format!("invalid daemon bind address: {error}"))
783 })?;
784 if bind_addr.port() == 0 {
785 return Err(GlovesError::InvalidInput(
786 "daemon bind port must be non-zero".to_owned(),
787 ));
788 }
789 if !bind_addr.ip().is_loopback() {
790 return Err(GlovesError::InvalidInput(
791 "daemon bind address must be loopback".to_owned(),
792 ));
793 }
794
795 let io_timeout_seconds = raw
796 .io_timeout_seconds
797 .unwrap_or(DEFAULT_DAEMON_IO_TIMEOUT_SECONDS);
798 if io_timeout_seconds == 0 {
799 return Err(GlovesError::InvalidInput(
800 "daemon io_timeout_seconds must be greater than zero".to_owned(),
801 ));
802 }
803
804 let request_limit_bytes = raw
805 .request_limit_bytes
806 .unwrap_or(DEFAULT_DAEMON_REQUEST_LIMIT_BYTES);
807 if request_limit_bytes == 0 {
808 return Err(GlovesError::InvalidInput(
809 "daemon request_limit_bytes must be greater than zero".to_owned(),
810 ));
811 }
812
813 Ok(DaemonBootstrapConfig {
814 bind,
815 io_timeout_seconds,
816 request_limit_bytes,
817 })
818}
819
820fn merge_secret_access_policy(
821 policies: &mut BTreeMap<String, SecretAccessPolicy>,
822 agent_name: &str,
823 next_policy: SecretAccessPolicy,
824) {
825 let entry = policies
826 .entry(agent_name.to_owned())
827 .or_insert_with(|| SecretAccessPolicy {
828 refs: Vec::new(),
829 operations: Vec::new(),
830 });
831 entry.refs.extend(next_policy.refs);
832 entry.operations.extend(next_policy.operations);
833 dedup_preserving_order(&mut entry.refs);
834 dedup_preserving_order(&mut entry.operations);
835}
836
837fn dedup_preserving_order<T>(values: &mut Vec<T>)
838where
839 T: Clone + Ord,
840{
841 let mut seen = BTreeSet::new();
842 values.retain(|value| seen.insert(value.clone()));
843}
844
845fn resolve_default_config(raw: &DefaultsConfigFile) -> Result<DefaultBootstrapConfig> {
846 let agent_literal = raw
847 .agent_id
848 .as_deref()
849 .unwrap_or(DEFAULT_AGENT_ID)
850 .to_owned();
851 let agent_id = AgentId::new(&agent_literal)?;
852
853 let secret_ttl_days = raw.secret_ttl_days.unwrap_or(DEFAULT_SECRET_TTL_DAYS);
854 if secret_ttl_days <= 0 {
855 return Err(GlovesError::InvalidInput(
856 "defaults.secret_ttl_days must be greater than zero".to_owned(),
857 ));
858 }
859
860 let vault_mount_ttl = raw
861 .vault_mount_ttl
862 .as_deref()
863 .unwrap_or(DEFAULT_VAULT_MOUNT_TTL)
864 .to_owned();
865 validate_duration_literal(&vault_mount_ttl, "defaults.vault_mount_ttl")?;
866
867 let vault_secret_ttl_days = raw
868 .vault_secret_ttl_days
869 .unwrap_or(DEFAULT_VAULT_SECRET_TTL_DAYS);
870 if vault_secret_ttl_days <= 0 {
871 return Err(GlovesError::InvalidInput(
872 "defaults.vault_secret_ttl_days must be greater than zero".to_owned(),
873 ));
874 }
875
876 let vault_secret_length_bytes = raw
877 .vault_secret_length_bytes
878 .unwrap_or(DEFAULT_VAULT_SECRET_LENGTH_BYTES);
879 if vault_secret_length_bytes == 0 {
880 return Err(GlovesError::InvalidInput(
881 "defaults.vault_secret_length_bytes must be greater than zero".to_owned(),
882 ));
883 }
884
885 Ok(DefaultBootstrapConfig {
886 agent_id,
887 secret_ttl_days,
888 vault_mount_ttl,
889 vault_secret_ttl_days,
890 vault_secret_length_bytes,
891 })
892}
893
894fn validate_agent_policy<PrivatePathValue, VaultMountValue>(
895 agent_name: &str,
896 policy: &AgentAccessFile,
897 private_paths: &BTreeMap<String, PrivatePathValue>,
898 vault_mounts: &BTreeMap<String, VaultMountValue>,
899) -> Result<()> {
900 let has_legacy_path_policy = !policy.paths.is_empty() || !policy.operations.is_empty();
901 let has_secret_policy = policy.secrets.is_some();
902 let has_vault_policy = policy.vault.is_some();
903
904 if !has_legacy_path_policy && !has_secret_policy && !has_vault_policy {
905 return Err(GlovesError::InvalidInput(format!(
906 "agent '{agent_name}' must include at least one access policy"
907 )));
908 }
909
910 if policy.paths.is_empty() != policy.operations.is_empty() {
911 return Err(GlovesError::InvalidInput(format!(
912 "agent '{agent_name}' must define both paths and operations for legacy private path access"
913 )));
914 }
915
916 if has_legacy_path_policy {
917 let mut path_aliases = BTreeSet::new();
918 for alias in &policy.paths {
919 if !path_aliases.insert(alias.as_str()) {
920 return Err(GlovesError::InvalidInput(format!(
921 "agent '{agent_name}' contains duplicate private path alias '{alias}'"
922 )));
923 }
924 if !private_paths.is_empty() && !private_paths.contains_key(alias) {
925 return Err(GlovesError::InvalidInput(format!(
926 "agent '{agent_name}' references unknown private path alias '{alias}'"
927 )));
928 }
929 }
930
931 let mut operations = BTreeSet::new();
932 for operation in &policy.operations {
933 if !operations.insert(*operation) {
934 return Err(GlovesError::InvalidInput(format!(
935 "agent '{agent_name}' contains duplicate operation '{operation:?}'"
936 )));
937 }
938 }
939 }
940
941 if let Some(secret_policy) = policy.secrets.as_ref() {
942 validate_agent_secret_access_policy(agent_name, secret_policy)?;
943 }
944 if let Some(vault_policy) = policy.vault.as_ref() {
945 validate_agent_vault_access_policy(agent_name, vault_policy, vault_mounts)?;
946 }
947
948 Ok(())
949}
950
951fn validate_agent_secret_access_policy(
952 agent_name: &str,
953 policy: &AgentSecretsAccessFile,
954) -> Result<()> {
955 let policy = SecretAccessFile {
956 refs: policy.refs.clone(),
957 operations: policy.operations.clone(),
958 };
959 validate_secret_access_policy(agent_name, &policy)
960}
961
962fn validate_agent_vault_access_policy(
963 agent_name: &str,
964 policy: &AgentVaultAccessFile,
965 vault_mounts: &BTreeMap<String, impl Sized>,
966) -> Result<()> {
967 if policy.mounts.is_empty() {
968 return Err(GlovesError::InvalidInput(format!(
969 "vault access for agent '{agent_name}' must include at least one mount"
970 )));
971 }
972 if policy.operations.is_empty() {
973 return Err(GlovesError::InvalidInput(format!(
974 "vault access for agent '{agent_name}' must include at least one operation"
975 )));
976 }
977
978 let mut mount_names = BTreeSet::new();
979 for mount_name in &policy.mounts {
980 if !mount_names.insert(mount_name.as_str()) {
981 return Err(GlovesError::InvalidInput(format!(
982 "vault access for agent '{agent_name}' contains duplicate mount '{mount_name}'"
983 )));
984 }
985 if mount_name != "*" && !vault_mounts.is_empty() && !vault_mounts.contains_key(mount_name) {
986 return Err(GlovesError::InvalidInput(format!(
987 "vault access for agent '{agent_name}' references unknown mount '{mount_name}'"
988 )));
989 }
990 }
991
992 let mut operations = BTreeSet::new();
993 for operation in &policy.operations {
994 if !operations.insert(*operation) {
995 return Err(GlovesError::InvalidInput(format!(
996 "vault access for agent '{agent_name}' contains duplicate operation '{operation:?}'"
997 )));
998 }
999 }
1000
1001 Ok(())
1002}
1003
1004fn validate_secret_access_policy(agent_name: &str, policy: &SecretAccessFile) -> Result<()> {
1005 if policy.refs.is_empty() {
1006 return Err(GlovesError::InvalidInput(format!(
1007 "secret ACL for agent '{agent_name}' must include at least one ref pattern"
1008 )));
1009 }
1010 if policy.operations.is_empty() {
1011 return Err(GlovesError::InvalidInput(format!(
1012 "secret ACL for agent '{agent_name}' must include at least one operation"
1013 )));
1014 }
1015
1016 let mut patterns = BTreeSet::new();
1017 for pattern in &policy.refs {
1018 validate_secret_pattern(pattern)?;
1019 if !patterns.insert(pattern.as_str()) {
1020 return Err(GlovesError::InvalidInput(format!(
1021 "secret ACL for agent '{agent_name}' contains duplicate pattern '{pattern}'"
1022 )));
1023 }
1024 }
1025
1026 let mut operations = BTreeSet::new();
1027 for operation in &policy.operations {
1028 if !operations.insert(*operation) {
1029 return Err(GlovesError::InvalidInput(format!(
1030 "secret ACL for agent '{agent_name}' contains duplicate operation '{operation:?}'"
1031 )));
1032 }
1033 }
1034
1035 Ok(())
1036}
1037
1038fn validate_integration_config(name: &str, integration: &IntegrationConfigFile) -> Result<()> {
1039 validate_alias(name)?;
1040 let agent_literal = integration.agent.as_deref().ok_or_else(|| {
1041 GlovesError::InvalidInput(format!("integration '{name}' must declare an owning agent"))
1042 })?;
1043 AgentId::new(agent_literal)?;
1044 let _ = normalized_integration_segments(&integration.profiles, "profiles", name)?;
1045 let _ = normalized_integration_segments(&integration.slots, "slots", name)?;
1046 Ok(())
1047}
1048
1049fn normalized_integration_segments(
1050 values: &[String],
1051 field_name: &str,
1052 integration_name: &str,
1053) -> Result<Vec<String>> {
1054 let mut normalized = Vec::with_capacity(values.len());
1055 let mut seen = BTreeSet::new();
1056 for value in values {
1057 let trimmed = value.trim();
1058 if trimmed.is_empty() {
1059 return Err(GlovesError::InvalidInput(format!(
1060 "integration '{integration_name}' contains an empty {field_name} entry"
1061 )));
1062 }
1063 validate_alias(trimmed).map_err(|_| {
1064 GlovesError::InvalidInput(format!(
1065 "integration '{integration_name}' has invalid {field_name} entry '{trimmed}'"
1066 ))
1067 })?;
1068 if !seen.insert(trimmed.to_owned()) {
1069 return Err(GlovesError::InvalidInput(format!(
1070 "integration '{integration_name}' contains duplicate {field_name} entry '{trimmed}'"
1071 )));
1072 }
1073 normalized.push(trimmed.to_owned());
1074 }
1075 Ok(normalized)
1076}
1077
1078fn validate_secret_pipe_command_policy(
1079 command: &str,
1080 policy: &SecretPipeCommandPolicyFile,
1081) -> Result<()> {
1082 validate_pipe_command_name(command)?;
1083
1084 if !policy.require_url && policy.url_prefixes.is_empty() {
1085 return Err(GlovesError::InvalidInput(format!(
1086 "secrets.pipe.commands.{command} must set require_url = true or include at least one url_prefix"
1087 )));
1088 }
1089 if policy.require_url && policy.url_prefixes.is_empty() {
1090 return Err(GlovesError::InvalidInput(format!(
1091 "secrets.pipe.commands.{command} requires at least one url_prefix"
1092 )));
1093 }
1094
1095 let mut unique_prefixes = BTreeSet::new();
1096 for url_prefix in &policy.url_prefixes {
1097 validate_pipe_url_prefix(command, url_prefix)?;
1098 if !unique_prefixes.insert(url_prefix.as_str()) {
1099 return Err(GlovesError::InvalidInput(format!(
1100 "secrets.pipe.commands.{command} contains duplicate url_prefix '{url_prefix}'"
1101 )));
1102 }
1103 }
1104
1105 Ok(())
1106}
1107
1108fn validate_pipe_command_name(command: &str) -> Result<()> {
1109 if command.is_empty()
1110 || !command
1111 .chars()
1112 .all(|character| character.is_ascii_alphanumeric() || "._+-".contains(character))
1113 {
1114 return Err(GlovesError::InvalidInput(format!(
1115 "secrets.pipe.commands.{command} must be a bare executable name"
1116 )));
1117 }
1118 Ok(())
1119}
1120
1121fn validate_pipe_url_prefix(command: &str, url_prefix: &str) -> Result<()> {
1122 if url_prefix.trim().is_empty() {
1123 return Err(GlovesError::InvalidInput(format!(
1124 "secrets.pipe.commands.{command} contains an empty url_prefix"
1125 )));
1126 }
1127 if let Err(reason) = parse_policy_url_prefix(url_prefix) {
1128 return Err(GlovesError::InvalidInput(format!(
1129 "secrets.pipe.commands.{command} url_prefix '{url_prefix}' {reason}"
1130 )));
1131 }
1132 Ok(())
1133}
1134
1135fn parse_policy_url_prefix(url_prefix: &str) -> std::result::Result<(), String> {
1136 let remainder = if let Some(rest) = url_prefix.strip_prefix(URL_SCHEME_HTTP_PREFIX) {
1137 rest
1138 } else if let Some(rest) = url_prefix.strip_prefix(URL_SCHEME_HTTPS_PREFIX) {
1139 rest
1140 } else {
1141 return Err("must start with http:// or https://".to_owned());
1142 };
1143 if remainder.is_empty() {
1144 return Err("must include an authority after scheme".to_owned());
1145 }
1146
1147 let delimiter_index = remainder
1148 .find(|character: char| ['/', '?', '#'].contains(&character))
1149 .unwrap_or(remainder.len());
1150 let authority = &remainder[..delimiter_index];
1151 if authority.is_empty() {
1152 return Err("must include an authority after scheme".to_owned());
1153 }
1154 if authority.chars().any(char::is_whitespace) {
1155 return Err("must not contain whitespace in authority".to_owned());
1156 }
1157
1158 let suffix = &remainder[delimiter_index..];
1159 if suffix
1160 .chars()
1161 .any(|character| character == '?' || character == '#')
1162 {
1163 return Err("must not include query or fragment components".to_owned());
1164 }
1165 Ok(())
1166}
1167
1168fn validate_secret_pattern(pattern: &str) -> Result<()> {
1169 if pattern == "*" {
1170 return Ok(());
1171 }
1172
1173 if let Some(prefix) = pattern.strip_suffix("/*") {
1174 if prefix.is_empty() {
1175 return Err(GlovesError::InvalidInput(
1176 "secret ACL pattern '/*' is not allowed; use '*' for all secrets".to_owned(),
1177 ));
1178 }
1179 if prefix.contains('*') {
1180 return Err(GlovesError::InvalidInput(format!(
1181 "secret ACL pattern '{pattern}' may only use one trailing '*'"
1182 )));
1183 }
1184 SecretId::new(prefix).map_err(|_| {
1185 GlovesError::InvalidInput(format!(
1186 "secret ACL pattern '{pattern}' has an invalid namespace prefix"
1187 ))
1188 })?;
1189 return Ok(());
1190 }
1191
1192 if pattern.contains('*') {
1193 return Err(GlovesError::InvalidInput(format!(
1194 "secret ACL pattern '{pattern}' must be '*', '<namespace>/*', or an exact secret id"
1195 )));
1196 }
1197
1198 SecretId::new(pattern).map_err(|_| {
1199 GlovesError::InvalidInput(format!(
1200 "secret ACL pattern '{pattern}' is not a valid secret id"
1201 ))
1202 })?;
1203 Ok(())
1204}
1205
1206fn secret_pattern_matches(pattern: &str, secret_name: &str) -> bool {
1207 if pattern == "*" {
1208 return true;
1209 }
1210 if let Some(prefix) = pattern.strip_suffix("/*") {
1211 return secret_name.len() > prefix.len()
1212 && secret_name.starts_with(prefix)
1213 && secret_name.as_bytes().get(prefix.len()) == Some(&b'/');
1214 }
1215 secret_name == pattern
1216}
1217
1218fn resolve_path_value(value: &str, source_dir: &Path) -> Result<PathBuf> {
1219 validate_path_literal(value, "path")?;
1220
1221 let expanded = expand_home(value)?;
1222 let absolute = if expanded.is_absolute() {
1223 expanded
1224 } else {
1225 source_dir.join(expanded)
1226 };
1227
1228 if let Ok(canonical) = fs::canonicalize(&absolute) {
1229 return Ok(canonical);
1230 }
1231 Ok(normalize_path(&absolute))
1232}
1233
1234fn validate_path_literal(value: &str, label: &str) -> Result<()> {
1235 if value.trim().is_empty() {
1236 return Err(GlovesError::InvalidInput(format!(
1237 "{label} cannot be empty"
1238 )));
1239 }
1240 Ok(())
1241}
1242
1243fn validate_alias(alias: &str) -> Result<()> {
1244 if alias.is_empty() {
1245 return Err(GlovesError::InvalidInput(
1246 "private path alias cannot be empty".to_owned(),
1247 ));
1248 }
1249 if !alias
1250 .chars()
1251 .all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-')
1252 {
1253 return Err(GlovesError::InvalidInput(format!(
1254 "invalid private path alias '{}': use [a-zA-Z0-9_-]",
1255 alias
1256 )));
1257 }
1258 Ok(())
1259}
1260
1261fn validate_duration_literal(value: &str, label: &str) -> Result<()> {
1262 if value.is_empty() {
1263 return Err(GlovesError::InvalidInput(format!(
1264 "{label} cannot be empty"
1265 )));
1266 }
1267
1268 let (number, unit) = value.split_at(value.len().saturating_sub(1));
1269 let amount = number.parse::<i64>().map_err(|_| {
1270 GlovesError::InvalidInput(format!("{label} must be a duration like 30m, 1h, or 7d"))
1271 })?;
1272 if amount <= 0 {
1273 return Err(GlovesError::InvalidInput(format!(
1274 "{label} must be greater than zero"
1275 )));
1276 }
1277
1278 if !matches!(unit, "s" | "m" | "h" | "d") {
1279 return Err(GlovesError::InvalidInput(format!(
1280 "{label} must use one of s, m, h, d"
1281 )));
1282 }
1283
1284 Ok(())
1285}
1286
1287fn expand_home(value: &str) -> Result<PathBuf> {
1288 if value == "~" {
1289 let home = std::env::var_os("HOME")
1290 .ok_or_else(|| GlovesError::InvalidInput("HOME is not set".to_owned()))?;
1291 return Ok(PathBuf::from(home));
1292 }
1293
1294 if let Some(rest) = value.strip_prefix("~/") {
1295 let home = std::env::var_os("HOME")
1296 .ok_or_else(|| GlovesError::InvalidInput("HOME is not set".to_owned()))?;
1297 return Ok(PathBuf::from(home).join(rest));
1298 }
1299
1300 if value.starts_with('~') {
1301 return Err(GlovesError::InvalidInput(
1302 "only '~' and '~/' home expansion are supported".to_owned(),
1303 ));
1304 }
1305
1306 Ok(PathBuf::from(value))
1307}
1308
1309fn normalize_path(path: &Path) -> PathBuf {
1310 let is_absolute = path.is_absolute();
1311 let mut normalized = PathBuf::new();
1312
1313 for component in path.components() {
1314 match component {
1315 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
1316 Component::RootDir => normalized.push(component.as_os_str()),
1317 Component::CurDir => {}
1318 Component::ParentDir => {
1319 if !normalized.pop() && !is_absolute {
1320 normalized.push("..");
1321 }
1322 }
1323 Component::Normal(part) => normalized.push(part),
1324 }
1325 }
1326
1327 if normalized.as_os_str().is_empty() {
1328 if is_absolute {
1329 PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
1330 } else {
1331 PathBuf::from(".")
1332 }
1333 } else {
1334 normalized
1335 }
1336}
1337
1338fn absolutize_path(path: &Path, cwd: &Path) -> PathBuf {
1339 if path.is_absolute() {
1340 normalize_path(path)
1341 } else {
1342 normalize_path(&cwd.join(path))
1343 }
1344}
1345
1346fn validate_config_file_permissions(path: &Path, has_private_paths: bool) -> Result<()> {
1347 let metadata = fs::symlink_metadata(path)?;
1348 if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
1349 return Err(GlovesError::InvalidInput(format!(
1350 "config path must be a regular file: {}",
1351 path.display()
1352 )));
1353 }
1354
1355 #[cfg(unix)]
1356 {
1357 let mode = metadata.permissions().mode() & 0o777;
1358 if mode & 0o022 != 0 {
1359 return Err(GlovesError::InvalidInput(format!(
1360 "config file must not be group/world writable: {}",
1361 path.display()
1362 )));
1363 }
1364
1365 if has_private_paths {
1366 let has_exec_bits = mode & 0o111 != 0;
1367 let has_world_bits = mode & 0o007 != 0;
1368 let has_group_write_or_exec = mode & 0o030 != 0;
1369 if has_exec_bits || has_world_bits || has_group_write_or_exec {
1370 return Err(GlovesError::InvalidInput(format!(
1371 "config file with private paths must be private (recommended 0600/0640): {}",
1372 path.display()
1373 )));
1374 }
1375 }
1376 }
1377
1378 Ok(())
1379}
1380
1381fn is_regular_config_candidate(path: &Path) -> bool {
1382 let Ok(metadata) = fs::symlink_metadata(path) else {
1383 return false;
1384 };
1385 !metadata.file_type().is_symlink() && metadata.file_type().is_file()
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391 use std::{
1392 ffi::OsString,
1393 sync::{Mutex, OnceLock},
1394 time::{SystemTime, UNIX_EPOCH},
1395 };
1396
1397 #[cfg(unix)]
1398 use std::os::unix::fs::{symlink, PermissionsExt};
1399
1400 static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1401
1402 fn test_lock() -> std::sync::MutexGuard<'static, ()> {
1403 TEST_LOCK
1404 .get_or_init(|| Mutex::new(()))
1405 .lock()
1406 .unwrap_or_else(|poisoned| poisoned.into_inner())
1407 }
1408
1409 fn unique_temp_dir(label: &str) -> PathBuf {
1410 let unique = SystemTime::now()
1411 .duration_since(UNIX_EPOCH)
1412 .unwrap_or_default()
1413 .as_nanos();
1414 let temp_root = PathBuf::from("/tmp");
1415 let base_dir = if temp_root.is_dir() {
1416 temp_root
1417 } else {
1418 std::env::temp_dir()
1419 };
1420 let path = base_dir.join(format!(
1421 "gloves-config-{label}-{}-{unique}",
1422 std::process::id()
1423 ));
1424 fs::create_dir_all(&path).unwrap();
1425 path
1426 }
1427
1428 fn cleanup_dir(path: &Path) {
1429 let _ = fs::remove_dir_all(path);
1430 }
1431
1432 fn valid_config(root_literal: &str, private_path_literal: &str) -> String {
1433 format!(
1434 r#"
1435version = 1
1436
1437[paths]
1438root = "{root_literal}"
1439
1440[private_paths]
1441runtime = "{private_path_literal}"
1442
1443[daemon]
1444bind = "127.0.0.1:7789"
1445io_timeout_seconds = 9
1446request_limit_bytes = 32768
1447
1448[vault]
1449mode = "required"
1450
1451[defaults]
1452agent_id = "devy"
1453secret_ttl_days = 7
1454vault_mount_ttl = "2h"
1455vault_secret_ttl_days = 90
1456vault_secret_length_bytes = 48
1457
1458[agents.devy]
1459paths = ["runtime"]
1460operations = ["read", "write"]
1461
1462[secrets.acl.devy]
1463paths = ["agents/devy/*", "shared/database-url"]
1464operations = ["read", "list"]
1465
1466[secrets.pipe.commands.curl]
1467require_url = true
1468url_prefixes = ["https://api.example.com/v1/"]
1469"#
1470 )
1471 }
1472
1473 struct HomeGuard {
1474 previous_home: Option<OsString>,
1475 }
1476
1477 impl HomeGuard {
1478 fn set(home: &Path) -> Self {
1479 let previous_home = std::env::var_os("HOME");
1480 std::env::set_var("HOME", home);
1481 Self { previous_home }
1482 }
1483 }
1484
1485 impl Drop for HomeGuard {
1486 fn drop(&mut self) {
1487 if let Some(previous_home) = &self.previous_home {
1488 std::env::set_var("HOME", previous_home);
1489 } else {
1490 std::env::remove_var("HOME");
1491 }
1492 }
1493 }
1494
1495 #[test]
1496 fn parse_from_str_builds_effective_config_and_accessors() {
1497 let _lock = test_lock();
1498 let temp_dir = unique_temp_dir("parse");
1499 let source_path = temp_dir.join(CONFIG_FILE_NAME);
1500 let root_dir = temp_dir.join("secrets-root");
1501 let private_dir = temp_dir.join("private").join("runtime");
1502 fs::create_dir_all(&root_dir).unwrap();
1503 fs::create_dir_all(&private_dir).unwrap();
1504
1505 let config = GlovesConfig::parse_from_str(
1506 &valid_config("./secrets-root", "./private/runtime"),
1507 &source_path,
1508 )
1509 .unwrap();
1510
1511 assert_eq!(config.source_path, source_path);
1512 assert_eq!(config.root, fs::canonicalize(&root_dir).unwrap());
1513 assert_eq!(
1514 config.private_paths.get("runtime"),
1515 Some(&fs::canonicalize(&private_dir).unwrap())
1516 );
1517 assert_eq!(config.daemon.bind, "127.0.0.1:7789");
1518 assert_eq!(config.daemon.io_timeout_seconds, 9);
1519 assert_eq!(config.daemon.request_limit_bytes, 32768);
1520 assert_eq!(config.vault.mode, VaultMode::Required);
1521 assert_eq!(config.defaults.agent_id.as_str(), "devy");
1522 assert_eq!(config.defaults.secret_ttl_days, 7);
1523 assert_eq!(config.defaults.vault_mount_ttl, "2h");
1524 assert_eq!(config.defaults.vault_secret_ttl_days, 90);
1525 assert_eq!(config.defaults.vault_secret_length_bytes, 48);
1526 assert!(config.has_secret_acl());
1527
1528 let agent_id = AgentId::new("devy").unwrap();
1529 let paths = config.agent_paths(&agent_id).unwrap();
1530 assert_eq!(paths.len(), 1);
1531 assert_eq!(paths[0].alias, "runtime");
1532 assert_eq!(
1533 paths[0].operations,
1534 vec![PathOperation::Read, PathOperation::Write]
1535 );
1536
1537 let secret_policy = config.secret_access_policy(&agent_id).unwrap();
1538 assert!(secret_policy.allows_operation(SecretAclOperation::Read));
1539 assert!(secret_policy.allows_secret("agents/devy/api-keys/anthropic"));
1540 assert!(secret_policy.allows_secret("shared/database-url"));
1541 assert!(!secret_policy.allows_secret("agents/webhook/api-keys/anthropic"));
1542
1543 let pipe_policy = config.secret_pipe_command_policy("curl").unwrap();
1544 assert!(pipe_policy.require_url);
1545 assert_eq!(
1546 pipe_policy.url_prefixes,
1547 vec!["https://api.example.com/v1/".to_owned()]
1548 );
1549 assert!(config
1550 .secret_access_policy(&AgentId::new("webhook").unwrap())
1551 .is_none());
1552 assert!(config.secret_pipe_command_policy("wget").is_none());
1553
1554 let mut config_without_acl = config.clone();
1555 config_without_acl.secret_access.clear();
1556 assert!(!config_without_acl.has_secret_acl());
1557
1558 cleanup_dir(&temp_dir);
1559 }
1560
1561 #[test]
1562 fn resolve_config_path_honors_flag_env_discovery_and_no_config() {
1563 let _lock = test_lock();
1564 let temp_dir = unique_temp_dir("resolve");
1565 let nested_dir = temp_dir.join("workspace").join("nested");
1566 fs::create_dir_all(&nested_dir).unwrap();
1567
1568 let discovered_path = temp_dir.join("workspace").join(CONFIG_FILE_NAME);
1569 fs::write(&discovered_path, "version = 1\n").unwrap();
1570
1571 let flag_path = nested_dir.join("custom.toml");
1572 fs::write(&flag_path, "version = 1\n").unwrap();
1573 let env_path = nested_dir.join("env.toml");
1574 fs::write(&env_path, "version = 1\n").unwrap();
1575
1576 let flag_selection =
1577 resolve_config_path(Some(Path::new("custom.toml")), None, false, &nested_dir).unwrap();
1578 assert_eq!(flag_selection.source, ConfigSource::Flag);
1579 assert_eq!(flag_selection.path, Some(flag_path.clone()));
1580
1581 let env_selection =
1582 resolve_config_path(None, Some("env.toml"), false, &nested_dir).unwrap();
1583 assert_eq!(env_selection.source, ConfigSource::Env);
1584 assert_eq!(env_selection.path, Some(env_path.clone()));
1585
1586 let discovered_selection = resolve_config_path(None, None, false, &nested_dir).unwrap();
1587 assert_eq!(discovered_selection.source, ConfigSource::Discovered);
1588 assert_eq!(discovered_selection.path, Some(discovered_path.clone()));
1589
1590 let none_selection = resolve_config_path(None, None, true, &nested_dir).unwrap();
1591 assert_eq!(none_selection.source, ConfigSource::None);
1592 assert!(none_selection.path.is_none());
1593
1594 let env_error = resolve_config_path(None, Some(" "), false, &nested_dir).unwrap_err();
1595 assert!(env_error
1596 .to_string()
1597 .contains("GLOVES_CONFIG cannot be empty"));
1598
1599 let missing_error =
1600 resolve_config_path(Some(Path::new("missing.toml")), None, false, &nested_dir)
1601 .unwrap_err();
1602 assert!(missing_error
1603 .to_string()
1604 .contains("config file must be a regular file"));
1605
1606 cleanup_dir(&temp_dir);
1607 }
1608
1609 #[test]
1610 fn helper_functions_cover_path_resolution_and_home_expansion() {
1611 let _lock = test_lock();
1612 let temp_dir = unique_temp_dir("paths");
1613 let home_dir = temp_dir.join("home");
1614 fs::create_dir_all(&home_dir).unwrap();
1615 let _home_guard = HomeGuard::set(&home_dir);
1616
1617 assert_eq!(expand_home("~").unwrap(), home_dir);
1618 assert_eq!(expand_home("~/bin").unwrap(), home_dir.join("bin"));
1619 assert!(expand_home("~other/bin")
1620 .unwrap_err()
1621 .to_string()
1622 .contains("only '~' and '~/' home expansion are supported"));
1623
1624 assert_eq!(
1625 normalize_path(Path::new("foo/./bar/../baz")),
1626 PathBuf::from("foo/baz")
1627 );
1628 assert_eq!(
1629 normalize_path(Path::new("/tmp/../var/./lib")),
1630 PathBuf::from("/var/lib")
1631 );
1632 assert_eq!(
1633 absolutize_path(Path::new("nested/../config.toml"), Path::new("/tmp/work")),
1634 PathBuf::from("/tmp/work/config.toml")
1635 );
1636 assert_eq!(
1637 absolutize_path(Path::new("/tmp/./gloves.toml"), Path::new("/unused")),
1638 PathBuf::from("/tmp/gloves.toml")
1639 );
1640
1641 let resolved_existing = resolve_path_value("~/bin", Path::new("/unused")).unwrap();
1642 assert_eq!(resolved_existing, home_dir.join("bin"));
1643 let resolved_relative = resolve_path_value("./secrets/../secrets-root", &temp_dir).unwrap();
1644 assert_eq!(resolved_relative, temp_dir.join("secrets-root"));
1645
1646 assert!(validate_path_literal("", "paths.root")
1647 .unwrap_err()
1648 .to_string()
1649 .contains("paths.root cannot be empty"));
1650
1651 cleanup_dir(&temp_dir);
1652 }
1653
1654 #[test]
1655 fn daemon_defaults_and_vault_validation_cover_failure_modes() {
1656 let daemon_defaults = resolve_daemon_config(&DaemonConfigFile::default()).unwrap();
1657 assert_eq!(daemon_defaults.bind, DEFAULT_DAEMON_BIND);
1658 assert_eq!(
1659 daemon_defaults.io_timeout_seconds,
1660 DEFAULT_DAEMON_IO_TIMEOUT_SECONDS
1661 );
1662 assert_eq!(
1663 daemon_defaults.request_limit_bytes,
1664 DEFAULT_DAEMON_REQUEST_LIMIT_BYTES
1665 );
1666
1667 assert!(resolve_daemon_config(&DaemonConfigFile {
1668 bind: Some("127.0.0.1:0".to_owned()),
1669 io_timeout_seconds: None,
1670 request_limit_bytes: None,
1671 })
1672 .unwrap_err()
1673 .to_string()
1674 .contains("daemon bind port must be non-zero"));
1675 assert!(resolve_daemon_config(&DaemonConfigFile {
1676 bind: Some("0.0.0.0:7788".to_owned()),
1677 io_timeout_seconds: None,
1678 request_limit_bytes: None,
1679 })
1680 .unwrap_err()
1681 .to_string()
1682 .contains("daemon bind address must be loopback"));
1683 assert!(resolve_daemon_config(&DaemonConfigFile {
1684 bind: None,
1685 io_timeout_seconds: Some(0),
1686 request_limit_bytes: None,
1687 })
1688 .unwrap_err()
1689 .to_string()
1690 .contains("io_timeout_seconds must be greater than zero"));
1691 assert!(resolve_daemon_config(&DaemonConfigFile {
1692 bind: None,
1693 io_timeout_seconds: None,
1694 request_limit_bytes: Some(0),
1695 })
1696 .unwrap_err()
1697 .to_string()
1698 .contains("request_limit_bytes must be greater than zero"));
1699
1700 let default_config = resolve_default_config(&DefaultsConfigFile::default()).unwrap();
1701 assert_eq!(default_config.agent_id.as_str(), DEFAULT_AGENT_ID);
1702 assert_eq!(default_config.secret_ttl_days, DEFAULT_SECRET_TTL_DAYS);
1703 assert_eq!(default_config.vault_mount_ttl, DEFAULT_VAULT_MOUNT_TTL);
1704 assert_eq!(
1705 default_config.vault_secret_ttl_days,
1706 DEFAULT_VAULT_SECRET_TTL_DAYS
1707 );
1708 assert_eq!(
1709 default_config.vault_secret_length_bytes,
1710 DEFAULT_VAULT_SECRET_LENGTH_BYTES
1711 );
1712
1713 assert!(resolve_default_config(&DefaultsConfigFile {
1714 agent_id: Some("bad agent".to_owned()),
1715 ..DefaultsConfigFile::default()
1716 })
1717 .is_err());
1718 assert!(resolve_default_config(&DefaultsConfigFile {
1719 secret_ttl_days: Some(0),
1720 ..DefaultsConfigFile::default()
1721 })
1722 .unwrap_err()
1723 .to_string()
1724 .contains("secret_ttl_days must be greater than zero"));
1725 assert!(resolve_default_config(&DefaultsConfigFile {
1726 vault_mount_ttl: Some("12x".to_owned()),
1727 ..DefaultsConfigFile::default()
1728 })
1729 .unwrap_err()
1730 .to_string()
1731 .contains("vault_mount_ttl must use one of s, m, h, d"));
1732 assert!(resolve_default_config(&DefaultsConfigFile {
1733 vault_secret_ttl_days: Some(0),
1734 ..DefaultsConfigFile::default()
1735 })
1736 .unwrap_err()
1737 .to_string()
1738 .contains("vault_secret_ttl_days must be greater than zero"));
1739 assert!(resolve_default_config(&DefaultsConfigFile {
1740 vault_secret_length_bytes: Some(0),
1741 ..DefaultsConfigFile::default()
1742 })
1743 .unwrap_err()
1744 .to_string()
1745 .contains("vault_secret_length_bytes must be greater than zero"));
1746
1747 assert_eq!(
1748 resolve_vault_config(&VaultConfigFile::default(), Path::new("."))
1749 .unwrap()
1750 .mode,
1751 VaultMode::Auto
1752 );
1753 assert_eq!(
1754 resolve_vault_config(
1755 &VaultConfigFile {
1756 mode: Some(VaultMode::Disabled),
1757 mounts: BTreeMap::new(),
1758 },
1759 Path::new(".")
1760 )
1761 .unwrap()
1762 .mode,
1763 VaultMode::Disabled
1764 );
1765
1766 assert!(validate_duration_literal("", "defaults.vault_mount_ttl")
1767 .unwrap_err()
1768 .to_string()
1769 .contains("defaults.vault_mount_ttl cannot be empty"));
1770 assert!(validate_duration_literal("0h", "defaults.vault_mount_ttl")
1771 .unwrap_err()
1772 .to_string()
1773 .contains("defaults.vault_mount_ttl must be greater than zero"));
1774 }
1775
1776 #[test]
1777 fn policy_validation_helpers_cover_duplicates_and_invalid_patterns() {
1778 let private_paths = BTreeMap::from([("runtime".to_owned(), PathBuf::from("/tmp/runtime"))]);
1779 let vault_mounts =
1780 BTreeMap::from([("contacts".to_owned(), PathBuf::from("/tmp/contacts"))]);
1781
1782 assert!(validate_agent_policy(
1783 "devy",
1784 &AgentAccessFile {
1785 paths: Vec::new(),
1786 operations: vec![PathOperation::Read],
1787 secrets: None,
1788 vault: None,
1789 },
1790 &private_paths,
1791 &vault_mounts,
1792 )
1793 .unwrap_err()
1794 .to_string()
1795 .contains("must define both paths and operations"));
1796 assert!(validate_agent_policy(
1797 "devy",
1798 &AgentAccessFile {
1799 paths: vec!["runtime".to_owned()],
1800 operations: Vec::new(),
1801 secrets: None,
1802 vault: None,
1803 },
1804 &private_paths,
1805 &vault_mounts,
1806 )
1807 .unwrap_err()
1808 .to_string()
1809 .contains("must define both paths and operations"));
1810 assert!(validate_agent_policy(
1811 "devy",
1812 &AgentAccessFile {
1813 paths: vec!["missing".to_owned()],
1814 operations: vec![PathOperation::Read],
1815 secrets: None,
1816 vault: None,
1817 },
1818 &private_paths,
1819 &vault_mounts,
1820 )
1821 .unwrap_err()
1822 .to_string()
1823 .contains("references unknown private path alias"));
1824 assert!(validate_agent_policy(
1825 "devy",
1826 &AgentAccessFile {
1827 paths: vec!["runtime".to_owned(), "runtime".to_owned()],
1828 operations: vec![PathOperation::Read],
1829 secrets: None,
1830 vault: None,
1831 },
1832 &private_paths,
1833 &vault_mounts,
1834 )
1835 .unwrap_err()
1836 .to_string()
1837 .contains("duplicate private path alias"));
1838 assert!(validate_agent_policy(
1839 "devy",
1840 &AgentAccessFile {
1841 paths: vec!["runtime".to_owned()],
1842 operations: vec![PathOperation::Read, PathOperation::Read],
1843 secrets: None,
1844 vault: None,
1845 },
1846 &private_paths,
1847 &vault_mounts,
1848 )
1849 .unwrap_err()
1850 .to_string()
1851 .contains("duplicate operation"));
1852
1853 let valid_secret_policy = SecretAccessFile {
1854 refs: vec!["agents/devy/*".to_owned(), "shared/database-url".to_owned()],
1855 operations: vec![SecretAclOperation::Read, SecretAclOperation::List],
1856 };
1857 validate_secret_access_policy("devy", &valid_secret_policy).unwrap();
1858 assert!(validate_secret_access_policy(
1859 "devy",
1860 &SecretAccessFile {
1861 refs: Vec::new(),
1862 operations: vec![SecretAclOperation::Read],
1863 },
1864 )
1865 .unwrap_err()
1866 .to_string()
1867 .contains("must include at least one ref pattern"));
1868 assert!(validate_secret_access_policy(
1869 "devy",
1870 &SecretAccessFile {
1871 refs: vec!["*".to_owned()],
1872 operations: Vec::new(),
1873 },
1874 )
1875 .unwrap_err()
1876 .to_string()
1877 .contains("must include at least one operation"));
1878 assert!(validate_secret_access_policy(
1879 "devy",
1880 &SecretAccessFile {
1881 refs: vec!["*".to_owned(), "*".to_owned()],
1882 operations: vec![SecretAclOperation::Read],
1883 },
1884 )
1885 .unwrap_err()
1886 .to_string()
1887 .contains("duplicate pattern"));
1888 assert!(validate_secret_access_policy(
1889 "devy",
1890 &SecretAccessFile {
1891 refs: vec!["*".to_owned()],
1892 operations: vec![SecretAclOperation::Read, SecretAclOperation::Read],
1893 },
1894 )
1895 .unwrap_err()
1896 .to_string()
1897 .contains("duplicate operation"));
1898
1899 validate_secret_pattern("*").unwrap();
1900 validate_secret_pattern("agents/devy/*").unwrap();
1901 validate_secret_pattern("shared/database-url").unwrap();
1902 assert!(validate_secret_pattern("/*")
1903 .unwrap_err()
1904 .to_string()
1905 .contains("is not allowed"));
1906 assert!(validate_secret_pattern("agents/*/broken")
1907 .unwrap_err()
1908 .to_string()
1909 .contains("must be '*', '<namespace>/*', or an exact secret id"));
1910 assert!(validate_secret_pattern("agents/devy*")
1911 .unwrap_err()
1912 .to_string()
1913 .contains("must be '*', '<namespace>/*', or an exact secret id"));
1914 assert!(validate_secret_pattern("bad secret")
1915 .unwrap_err()
1916 .to_string()
1917 .contains("is not a valid secret id"));
1918
1919 assert!(secret_pattern_matches("*", "shared/database-url"));
1920 assert!(secret_pattern_matches(
1921 "agents/devy/*",
1922 "agents/devy/api-keys/anthropic"
1923 ));
1924 assert!(!secret_pattern_matches("agents/devy/*", "agents/devy"));
1925 assert!(!secret_pattern_matches(
1926 "agents/devy/*",
1927 "agents/webhook/api-keys/anthropic"
1928 ));
1929 assert!(secret_pattern_matches(
1930 "shared/database-url",
1931 "shared/database-url"
1932 ));
1933 }
1934
1935 #[test]
1936 fn pipe_policy_and_raw_config_validation_cover_edge_cases() {
1937 validate_secret_pipe_command_policy(
1938 "curl",
1939 &SecretPipeCommandPolicyFile {
1940 require_url: true,
1941 url_prefixes: vec!["https://api.example.com/".to_owned()],
1942 },
1943 )
1944 .unwrap();
1945
1946 assert!(validate_pipe_command_name("curl").is_ok());
1947 assert!(validate_pipe_command_name("curl --fail")
1948 .unwrap_err()
1949 .to_string()
1950 .contains("must be a bare executable name"));
1951 assert!(validate_secret_pipe_command_policy(
1952 "curl",
1953 &SecretPipeCommandPolicyFile::default(),
1954 )
1955 .unwrap_err()
1956 .to_string()
1957 .contains("must set require_url = true or include at least one url_prefix"));
1958 assert!(validate_secret_pipe_command_policy(
1959 "curl",
1960 &SecretPipeCommandPolicyFile {
1961 require_url: true,
1962 url_prefixes: Vec::new(),
1963 },
1964 )
1965 .unwrap_err()
1966 .to_string()
1967 .contains("requires at least one url_prefix"));
1968 assert!(validate_secret_pipe_command_policy(
1969 "curl",
1970 &SecretPipeCommandPolicyFile {
1971 require_url: false,
1972 url_prefixes: vec![
1973 "https://api.example.com/".to_owned(),
1974 "https://api.example.com/".to_owned()
1975 ],
1976 },
1977 )
1978 .unwrap_err()
1979 .to_string()
1980 .contains("duplicate url_prefix"));
1981 assert!(validate_pipe_url_prefix("curl", " ")
1982 .unwrap_err()
1983 .to_string()
1984 .contains("contains an empty url_prefix"));
1985
1986 assert!(parse_policy_url_prefix("ftp://example.com")
1987 .unwrap_err()
1988 .contains("must start with http:// or https://"));
1989 assert!(parse_policy_url_prefix("https://")
1990 .unwrap_err()
1991 .contains("must include an authority after scheme"));
1992 assert!(parse_policy_url_prefix("https://bad host/path")
1993 .unwrap_err()
1994 .contains("must not contain whitespace in authority"));
1995 assert!(parse_policy_url_prefix("https://example.com/path?query")
1996 .unwrap_err()
1997 .contains("must not include query or fragment components"));
1998 assert!(parse_policy_url_prefix("https://example.com/path#fragment")
1999 .unwrap_err()
2000 .contains("must not include query or fragment components"));
2001
2002 assert!(validate_alias("runtime-1").is_ok());
2003 assert!(validate_alias("")
2004 .unwrap_err()
2005 .to_string()
2006 .contains("alias cannot be empty"));
2007 assert!(validate_alias("bad/alias")
2008 .unwrap_err()
2009 .to_string()
2010 .contains("invalid private path alias"));
2011
2012 assert!(
2013 GlovesConfig::parse_from_str("version = 3\n", Path::new("/tmp/.gloves.toml"))
2014 .unwrap_err()
2015 .to_string()
2016 .contains("unsupported config version")
2017 );
2018 assert!(GlovesConfig::parse_from_str(
2019 "version = 1\nunknown = true\n",
2020 Path::new("/tmp/.gloves.toml")
2021 )
2022 .unwrap_err()
2023 .to_string()
2024 .contains("invalid config TOML"));
2025 }
2026
2027 #[test]
2028 fn agent_paths_reports_unknown_alias_when_config_is_mutated() {
2029 let mut config = GlovesConfig {
2030 source_path: PathBuf::from("/tmp/.gloves.toml"),
2031 root: PathBuf::from("/tmp/root"),
2032 private_paths: BTreeMap::new(),
2033 daemon: DaemonBootstrapConfig {
2034 bind: DEFAULT_DAEMON_BIND.to_owned(),
2035 io_timeout_seconds: DEFAULT_DAEMON_IO_TIMEOUT_SECONDS,
2036 request_limit_bytes: DEFAULT_DAEMON_REQUEST_LIMIT_BYTES,
2037 },
2038 vault: VaultBootstrapConfig {
2039 mode: VaultMode::Auto,
2040 mounts: BTreeMap::new(),
2041 },
2042 defaults: DefaultBootstrapConfig {
2043 agent_id: AgentId::new(DEFAULT_AGENT_ID).unwrap(),
2044 secret_ttl_days: DEFAULT_SECRET_TTL_DAYS,
2045 vault_mount_ttl: DEFAULT_VAULT_MOUNT_TTL.to_owned(),
2046 vault_secret_ttl_days: DEFAULT_VAULT_SECRET_TTL_DAYS,
2047 vault_secret_length_bytes: DEFAULT_VAULT_SECRET_LENGTH_BYTES,
2048 },
2049 agents: BTreeMap::from([(
2050 "devy".to_owned(),
2051 AgentAccessPolicy {
2052 path_aliases: vec!["runtime".to_owned()],
2053 operations: vec![PathOperation::Read],
2054 },
2055 )]),
2056 secret_access: BTreeMap::new(),
2057 agent_vault_access: BTreeMap::new(),
2058 integrations: BTreeMap::new(),
2059 secret_pipe_commands: BTreeMap::new(),
2060 };
2061
2062 let error = config
2063 .agent_paths(&AgentId::new("devy").unwrap())
2064 .unwrap_err();
2065 assert!(error
2066 .to_string()
2067 .contains("references unknown private path alias"));
2068
2069 config
2070 .private_paths
2071 .insert("runtime".to_owned(), PathBuf::from("/tmp/runtime"));
2072 let missing_agent = config
2073 .agent_paths(&AgentId::new("webhook").unwrap())
2074 .unwrap_err();
2075 assert!(matches!(missing_agent, GlovesError::NotFound));
2076 }
2077
2078 #[test]
2079 fn discover_config_walks_upward_and_ignores_missing_candidates() {
2080 let _lock = test_lock();
2081 let temp_dir = unique_temp_dir("discover");
2082 let workspace_dir = temp_dir.join("workspace");
2083 let nested_dir = workspace_dir.join("nested").join("child");
2084 fs::create_dir_all(&nested_dir).unwrap();
2085
2086 let config_path = workspace_dir.join(CONFIG_FILE_NAME);
2087 fs::write(&config_path, "version = 1\n").unwrap();
2088
2089 assert_eq!(discover_config(&nested_dir), Some(config_path.clone()));
2090 assert!(is_regular_config_candidate(&config_path));
2091 assert!(!is_regular_config_candidate(&workspace_dir));
2092 assert_eq!(discover_config(temp_dir.join("missing")), None);
2093
2094 cleanup_dir(&temp_dir);
2095 }
2096
2097 #[cfg(unix)]
2098 #[test]
2099 fn load_from_path_and_permission_validation_cover_unix_rules() {
2100 let _lock = test_lock();
2101 let temp_dir = unique_temp_dir("load");
2102 let config_path = temp_dir.join(CONFIG_FILE_NAME);
2103 let regular_path = temp_dir.join("regular.toml");
2104 let symlink_path = temp_dir.join("config-link.toml");
2105 fs::create_dir_all(temp_dir.join("private").join("runtime")).unwrap();
2106 fs::create_dir_all(temp_dir.join("secrets-root")).unwrap();
2107
2108 fs::write(
2109 &config_path,
2110 valid_config("./secrets-root", "./private/runtime"),
2111 )
2112 .unwrap();
2113 fs::set_permissions(&config_path, fs::Permissions::from_mode(0o640)).unwrap();
2114 let loaded = GlovesConfig::load_from_path(&config_path).unwrap();
2115 assert_eq!(loaded.defaults.agent_id.as_str(), "devy");
2116
2117 fs::write(®ular_path, "version = 1\n").unwrap();
2118 fs::set_permissions(®ular_path, fs::Permissions::from_mode(0o666)).unwrap();
2119 assert!(validate_config_file_permissions(®ular_path, false)
2120 .unwrap_err()
2121 .to_string()
2122 .contains("must not be group/world writable"));
2123
2124 fs::set_permissions(®ular_path, fs::Permissions::from_mode(0o750)).unwrap();
2125 assert!(validate_config_file_permissions(®ular_path, true)
2126 .unwrap_err()
2127 .to_string()
2128 .contains("must be private"));
2129
2130 symlink(®ular_path, &symlink_path).unwrap();
2131 assert!(validate_config_file_permissions(&symlink_path, false)
2132 .unwrap_err()
2133 .to_string()
2134 .contains("must be a regular file"));
2135
2136 assert!(GlovesConfig::load_from_path(temp_dir.join("missing.toml"))
2137 .unwrap_err()
2138 .to_string()
2139 .contains("config file does not exist"));
2140
2141 cleanup_dir(&temp_dir);
2142 }
2143}