ito_core/templates/
schema_assets.rs1use super::{ResolvedSchema, SchemaSource, SchemaYaml, WorkflowError};
2use ito_config::ConfigContext;
3use ito_templates::{get_schema_file, schema_files};
4use std::collections::BTreeSet;
5use std::env;
6use std::fs;
7use std::path::{Component, Path, PathBuf};
8
9pub(super) fn package_schemas_dir() -> PathBuf {
23 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
24 let root = find_repository_root(&manifest_dir).unwrap_or(manifest_dir);
25 root.join("schemas")
26}
27
28fn find_repository_root(start: &Path) -> Option<PathBuf> {
29 for ancestor in start.ancestors() {
30 if ancestor.join(".git").exists() {
31 return Some(ancestor.to_path_buf());
32 }
33
34 let cargo_toml = ancestor.join("Cargo.toml");
35 if cargo_toml.exists()
36 && fs::read_to_string(&cargo_toml)
37 .map(|s| s.contains("[workspace]"))
38 .unwrap_or(false)
39 {
40 return Some(ancestor.to_path_buf());
41 }
42 }
43
44 None
45}
46
47pub(super) fn project_schemas_dir(ctx: &ConfigContext) -> Option<PathBuf> {
64 Some(
65 ctx.project_dir
66 .as_ref()?
67 .join(".ito")
68 .join("templates")
69 .join("schemas"),
70 )
71}
72
73pub(super) fn user_schemas_dir(ctx: &ConfigContext) -> Option<PathBuf> {
90 let data_home = match env::var("XDG_DATA_HOME") {
91 Ok(v) if !v.trim().is_empty() => Some(PathBuf::from(v)),
92 _ => ctx
93 .home_dir
94 .as_ref()
95 .map(|h| h.join(".local").join("share")),
96 }?;
97 Some(data_home.join("ito").join("schemas"))
98}
99
100pub(super) fn embedded_schema_names() -> Vec<String> {
118 let mut names: BTreeSet<String> = BTreeSet::new();
119 for file in schema_files() {
120 let mut parts = file.relative_path.split('/');
121 let Some(name) = parts.next() else {
122 continue;
123 };
124 if !name.is_empty() {
125 names.insert(name.to_string());
126 }
127 }
128 names.into_iter().collect()
129}
130
131pub(super) fn load_embedded_schema_yaml(name: &str) -> Result<Option<SchemaYaml>, WorkflowError> {
149 let path = format!("{name}/schema.yaml");
150 let Some(bytes) = get_schema_file(&path) else {
151 return Ok(None);
152 };
153
154 let s = std::str::from_utf8(bytes).map_err(|e| {
155 std::io::Error::new(
156 std::io::ErrorKind::InvalidData,
157 format!("embedded schema is not utf-8 ({path}): {e}"),
158 )
159 })?;
160 let schema = serde_yaml::from_str(s)?;
161 Ok(Some(schema))
162}
163
164pub(super) fn read_schema_template(
181 resolved: &ResolvedSchema,
182 template: &str,
183) -> Result<String, WorkflowError> {
184 if !is_safe_relative_path(template) {
185 return Err(WorkflowError::Io(std::io::Error::new(
186 std::io::ErrorKind::InvalidInput,
187 format!("invalid template path: {template}"),
188 )));
189 }
190
191 if resolved.source == SchemaSource::Embedded {
192 let path = format!("{}/templates/{template}", resolved.schema.name);
193 let bytes = get_schema_file(&path).ok_or_else(|| {
194 std::io::Error::new(
195 std::io::ErrorKind::NotFound,
196 format!("embedded template not found: {path}"),
197 )
198 })?;
199 let text = std::str::from_utf8(bytes).map_err(|e| {
200 std::io::Error::new(
201 std::io::ErrorKind::InvalidData,
202 format!("embedded template is not utf-8 ({path}): {e}"),
203 )
204 })?;
205 return Ok(text.to_string());
206 }
207
208 let path = resolved.schema_dir.join("templates").join(template);
209 ito_common::io::read_to_string_std(&path).map_err(WorkflowError::from)
210}
211
212pub(super) fn is_safe_relative_path(path: &str) -> bool {
213 if path.is_empty() {
214 return false;
215 }
216
217 if path.contains('\\') {
218 return false;
219 }
220
221 let p = Path::new(path);
222 if p.is_absolute() {
223 return false;
224 }
225
226 for component in p.components() {
227 match component {
228 Component::Normal(_) => {}
229 Component::CurDir
230 | Component::ParentDir
231 | Component::RootDir
232 | Component::Prefix(_) => {
233 return false;
234 }
235 }
236 }
237
238 true
239}
240
241pub(super) fn is_safe_schema_name(name: &str) -> bool {
242 is_safe_relative_path(name) && !name.contains('.')
243}
244
245#[derive(Debug, Clone)]
246pub struct ExportSchemasResult {
248 pub written: usize,
250 pub skipped: usize,
252}
253
254pub fn export_embedded_schemas(
258 to_dir: &Path,
259 force: bool,
260) -> Result<ExportSchemasResult, WorkflowError> {
261 let mut written = 0usize;
262 let mut skipped = 0usize;
263
264 for file in schema_files() {
265 let dest = to_dir.join(file.relative_path);
266 if let Some(parent) = dest.parent() {
267 fs::create_dir_all(parent)?;
268 }
269
270 if dest.exists() && !force {
271 skipped += 1;
272 continue;
273 }
274
275 fs::write(dest, file.contents)?;
276 written += 1;
277 }
278
279 Ok(ExportSchemasResult { written, skipped })
280}
281
282#[cfg(test)]
283mod tests {
284 use super::{is_safe_relative_path, is_safe_schema_name};
285
286 #[test]
287 fn safe_relative_path_validation_blocks_traversal_and_absolute_paths() {
288 assert!(is_safe_relative_path("proposal.md"));
289 assert!(is_safe_relative_path("nested/template.md"));
290
291 assert!(!is_safe_relative_path(""));
292 assert!(!is_safe_relative_path("../escape.md"));
293 assert!(!is_safe_relative_path("./relative.md"));
294 assert!(!is_safe_relative_path("/abs/path.md"));
295 assert!(!is_safe_relative_path("nested\\windows.md"));
296 }
297
298 #[test]
299 fn safe_schema_name_rejects_dot_segments_and_periods() {
300 assert!(is_safe_schema_name("spec-driven"));
301
302 assert!(!is_safe_schema_name("../spec-driven"));
303 assert!(!is_safe_schema_name("spec.driven"));
304 assert!(!is_safe_schema_name(""));
305 }
306}