zeroclaw_config/
skill_bundles.rs1use std::path::{Path, PathBuf};
14
15use crate::paths::normalize_lexical;
16use crate::schema::Config;
17
18#[must_use]
20pub fn default_directory(install_root: &Path, alias: &str) -> PathBuf {
21 install_root.join("shared").join("skills").join(alias)
22}
23
24pub fn resolve_directory(
29 config: &Config,
30 install_root: &Path,
31 alias: &str,
32) -> Result<PathBuf, BundleDirectoryError> {
33 let bundle = config
34 .skill_bundles
35 .get(alias)
36 .ok_or_else(|| BundleDirectoryError::UnknownBundle(alias.to_string()))?;
37
38 let configured = bundle
39 .directory
40 .as_deref()
41 .map(str::trim)
42 .filter(|s| !s.is_empty());
43
44 let path = match configured {
45 Some(raw) => {
46 let candidate = PathBuf::from(raw);
47 if candidate.is_absolute() {
48 candidate
49 } else {
50 install_root.join(candidate)
51 }
52 }
53 None => default_directory(install_root, alias),
54 };
55 Ok(path)
56}
57
58pub fn validate_directory(path: &Path, install_root: &Path) -> Result<(), BundleDirectoryError> {
61 let shared = install_root.join("shared");
62 let normalized = normalize_lexical(path);
63 let shared_normalized = normalize_lexical(&shared);
64 if !normalized.starts_with(&shared_normalized) {
65 return Err(BundleDirectoryError::EscapesShared {
66 path: normalized.display().to_string(),
67 shared: shared_normalized.display().to_string(),
68 });
69 }
70 Ok(())
71}
72
73pub fn validate_uniqueness(
75 config: &Config,
76 install_root: &Path,
77) -> Result<(), BundleDirectoryError> {
78 let mut seen: Vec<(String, PathBuf)> = Vec::with_capacity(config.skill_bundles.len());
79 for alias in config.skill_bundles.keys() {
80 let dir = resolve_directory(config, install_root, alias)?;
81 let normalized = normalize_lexical(&dir);
82 if let Some((other, _)) = seen.iter().find(|(_, p)| p == &normalized) {
83 return Err(BundleDirectoryError::DirectoryCollision {
84 path: normalized.display().to_string(),
85 first: other.clone(),
86 second: alias.clone(),
87 });
88 }
89 seen.push((alias.clone(), normalized));
90 }
91 Ok(())
92}
93
94#[derive(Debug, thiserror::Error)]
95pub enum BundleDirectoryError {
96 #[error("skill bundle '{0}' is not configured")]
97 UnknownBundle(String),
98
99 #[error(
100 "skill-bundle directory '{path}' escapes the shared workspace at '{shared}'; bundles must stay inside `<install>/shared/`"
101 )]
102 EscapesShared { path: String, shared: String },
103
104 #[error(
105 "skill-bundles '{first}' and '{second}' both resolve to directory '{path}'; each bundle must own a unique directory"
106 )]
107 DirectoryCollision {
108 path: String,
109 first: String,
110 second: String,
111 },
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::schema::SkillBundleConfig;
118
119 fn cfg_with_bundle(alias: &str, directory: Option<&str>) -> Config {
120 let mut cfg = Config::default();
121 cfg.skill_bundles.insert(
122 alias.to_string(),
123 SkillBundleConfig {
124 directory: directory.map(String::from),
125 ..Default::default()
126 },
127 );
128 cfg
129 }
130
131 #[test]
132 fn defaults_to_shared_skills_alias_when_unset() {
133 let cfg = cfg_with_bundle("alpha", None);
134 let root = Path::new("/tmp/install");
135 let resolved = resolve_directory(&cfg, root, "alpha").unwrap();
136 assert_eq!(resolved, root.join("shared/skills/alpha"));
137 }
138
139 #[test]
140 fn empty_directory_string_is_treated_as_unset() {
141 let cfg = cfg_with_bundle("alpha", Some(" "));
142 let root = Path::new("/tmp/install");
143 assert_eq!(
144 resolve_directory(&cfg, root, "alpha").unwrap(),
145 root.join("shared/skills/alpha"),
146 );
147 }
148
149 #[test]
150 fn validate_directory_rejects_dotdot_escape() {
151 let root = Path::new("/tmp/install");
152 let path = root.join("shared/../etc");
153 let err = validate_directory(&path, root).unwrap_err();
154 assert!(matches!(err, BundleDirectoryError::EscapesShared { .. }));
155 }
156
157 #[test]
158 fn uniqueness_rejects_two_bundles_pointing_at_same_dir() {
159 let mut cfg = Config::default();
160 cfg.skill_bundles.insert(
161 "alpha".into(),
162 SkillBundleConfig {
163 directory: Some("shared/skills/shared-pool".into()),
164 ..Default::default()
165 },
166 );
167 cfg.skill_bundles.insert(
168 "beta".into(),
169 SkillBundleConfig {
170 directory: Some("shared/skills/shared-pool".into()),
171 ..Default::default()
172 },
173 );
174 let err = validate_uniqueness(&cfg, Path::new("/tmp/install")).unwrap_err();
175 assert!(matches!(
176 err,
177 BundleDirectoryError::DirectoryCollision { .. }
178 ));
179 }
180
181 #[test]
182 fn uniqueness_passes_for_distinct_default_directories() {
183 let mut cfg = Config::default();
184 cfg.skill_bundles
185 .insert("alpha".into(), SkillBundleConfig::default());
186 cfg.skill_bundles
187 .insert("beta".into(), SkillBundleConfig::default());
188 validate_uniqueness(&cfg, Path::new("/tmp/install")).unwrap();
189 }
190}