ito_core/templates/
schema_assets.rs1use super::{ResolvedSchema, SchemaSource, SchemaYaml, ValidationYaml, 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 load_embedded_validation_yaml(
168 name: &str,
169) -> Result<Option<ValidationYaml>, WorkflowError> {
170 let path = format!("{name}/validation.yaml");
171 let Some(bytes) = get_schema_file(&path) else {
172 return Ok(None);
173 };
174
175 let s = std::str::from_utf8(bytes).map_err(|e| {
176 std::io::Error::new(
177 std::io::ErrorKind::InvalidData,
178 format!("embedded validation is not utf-8 ({path}): {e}"),
179 )
180 })?;
181 let parsed = serde_yaml::from_str(s)?;
182 Ok(Some(parsed))
183}
184
185pub(super) fn read_schema_template(
202 resolved: &ResolvedSchema,
203 template: &str,
204) -> Result<String, WorkflowError> {
205 if !is_safe_relative_path(template) {
206 return Err(WorkflowError::Io(std::io::Error::new(
207 std::io::ErrorKind::InvalidInput,
208 format!("invalid template path: {template}"),
209 )));
210 }
211
212 if resolved.source == SchemaSource::Embedded {
213 let path = format!("{}/templates/{template}", resolved.schema.name);
214 let bytes = get_schema_file(&path).ok_or_else(|| {
215 std::io::Error::new(
216 std::io::ErrorKind::NotFound,
217 format!("embedded template not found: {path}"),
218 )
219 })?;
220 let text = std::str::from_utf8(bytes).map_err(|e| {
221 std::io::Error::new(
222 std::io::ErrorKind::InvalidData,
223 format!("embedded template is not utf-8 ({path}): {e}"),
224 )
225 })?;
226 return Ok(text.to_string());
227 }
228
229 let path = resolved.schema_dir.join("templates").join(template);
230 ito_common::io::read_to_string_std(&path).map_err(WorkflowError::from)
231}
232
233pub(super) fn is_safe_relative_path(path: &str) -> bool {
234 if path.is_empty() {
235 return false;
236 }
237
238 if path.contains('\\') {
239 return false;
240 }
241
242 let p = Path::new(path);
243 if p.is_absolute() {
244 return false;
245 }
246
247 for component in p.components() {
248 match component {
249 Component::Normal(_) => {}
250 Component::CurDir
251 | Component::ParentDir
252 | Component::RootDir
253 | Component::Prefix(_) => {
254 return false;
255 }
256 }
257 }
258
259 true
260}
261
262pub(super) fn is_safe_schema_name(name: &str) -> bool {
263 is_safe_relative_path(name) && !name.contains('.')
264}
265
266#[derive(Debug, Clone)]
267pub struct ExportSchemasResult {
269 pub written: usize,
271 pub skipped: usize,
273}
274
275pub fn export_embedded_schemas(
279 to_dir: &Path,
280 force: bool,
281) -> Result<ExportSchemasResult, WorkflowError> {
282 let mut written = 0usize;
283 let mut skipped = 0usize;
284
285 for file in schema_files() {
286 let dest = to_dir.join(file.relative_path);
287 if let Some(parent) = dest.parent() {
288 fs::create_dir_all(parent)?;
289 }
290
291 if dest.exists() && !force {
292 skipped += 1;
293 continue;
294 }
295
296 fs::write(dest, file.contents)?;
297 written += 1;
298 }
299
300 Ok(ExportSchemasResult { written, skipped })
301}
302
303#[cfg(test)]
304mod tests {
305 use super::{is_safe_relative_path, is_safe_schema_name};
306
307 #[test]
308 fn safe_relative_path_validation_blocks_traversal_and_absolute_paths() {
309 assert!(is_safe_relative_path("proposal.md"));
310 assert!(is_safe_relative_path("nested/template.md"));
311
312 assert!(!is_safe_relative_path(""));
313 assert!(!is_safe_relative_path("../escape.md"));
314 assert!(!is_safe_relative_path("./relative.md"));
315 assert!(!is_safe_relative_path("/abs/path.md"));
316 assert!(!is_safe_relative_path("nested\\windows.md"));
317 }
318
319 #[test]
320 fn safe_schema_name_rejects_dot_segments_and_periods() {
321 assert!(is_safe_schema_name("spec-driven"));
322
323 assert!(!is_safe_schema_name("../spec-driven"));
324 assert!(!is_safe_schema_name("spec.driven"));
325 assert!(!is_safe_schema_name(""));
326 }
327}