omne_cli/commands/
validate.rs1use std::path::{Component, Path};
9
10use clap::Args as ClapArgs;
11use path_clean::PathClean;
12use walkdir::WalkDir;
13
14use crate::error::CliError;
15use crate::python;
16use crate::volume;
17
18const MAX_DEPTH: usize = 3;
23
24const REQUIRED_DIRS: &[&str] = &["core", "image", "cfg", "log"];
26
27const REQUIRED_IMAGE_DIRS: &[&str] = &["agents", "skills", "hooks"];
29
30const REQUIRED_IMAGE_FILES: &[&str] = &["context-map.md", "SYSTEM.md"];
32
33const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
36 "volume",
37 "distro",
38 "created",
39 "kernel-source",
40 "distro-source",
41];
42
43#[derive(Debug, ClapArgs)]
46pub struct Args {}
47
48pub fn run(_args: &Args) -> Result<(), CliError> {
49 let cwd = std::env::current_dir()
50 .map_err(|e| CliError::Io(format!("cannot determine current directory: {e}")))?;
51 validate_at_root(&cwd)
52}
53
54pub fn validate_at_root(start: &Path) -> Result<(), CliError> {
56 let root = volume::find_omne_root(start).ok_or(CliError::NotAVolume)?;
57 let omne = root.join(".omne");
58
59 let mut issues = Vec::new();
60 check_required_dirs(&omne, &mut issues);
61 check_core(&omne, &mut issues);
62 check_image(&omne, &mut issues);
63 check_manifest(&omne, &mut issues);
64 check_depth(&omne, &mut issues);
65 check_gate_runner(&omne, &mut issues);
66
67 if issues.is_empty() {
68 eprintln!("\x1b[32mVolume is valid.\x1b[0m");
69 Ok(())
70 } else {
71 Err(CliError::ValidationFailed { issues })
72 }
73}
74
75fn check_required_dirs(omne: &Path, issues: &mut Vec<String>) {
77 for &dir in REQUIRED_DIRS {
78 if !omne.join(dir).is_dir() {
79 issues.push(format!("missing required directory: .omne/{dir}/"));
80 }
81 }
82}
83
84fn check_core(omne: &Path, issues: &mut Vec<String>) {
86 let core = omne.join("core");
87 if !core.is_dir() {
88 return; }
90 if !core.join("manifest.json").is_file() {
91 issues.push("missing kernel manifest: core/manifest.json".to_string());
92 }
93}
94
95fn check_image(omne: &Path, issues: &mut Vec<String>) {
97 let image = omne.join("image");
98 if !image.is_dir() {
99 return; }
101 for &dir in REQUIRED_IMAGE_DIRS {
102 if !image.join(dir).is_dir() {
103 issues.push(format!("missing required image directory: image/{dir}/"));
104 }
105 }
106 for &file in REQUIRED_IMAGE_FILES {
107 if !image.join(file).is_file() {
108 issues.push(format!("missing required image file: image/{file}"));
109 }
110 }
111}
112
113fn check_manifest(omne: &Path, issues: &mut Vec<String>) {
115 let manifest = omne.join("MANIFEST.md");
116 if !manifest.is_file() {
117 issues.push("missing MANIFEST.md".to_string());
118 return;
119 }
120
121 let content = match std::fs::read_to_string(&manifest) {
122 Ok(c) => c,
123 Err(e) => {
124 issues.push(format!("cannot read MANIFEST.md: {e}"));
125 return;
126 }
127 };
128
129 let Some(yaml_body) = extract_frontmatter(&content) else {
131 issues.push("MANIFEST.md has no YAML frontmatter (---...---)".to_string());
132 return;
133 };
134
135 for &field in REQUIRED_MANIFEST_FIELDS {
138 let has_field = yaml_body
139 .lines()
140 .any(|line| line.starts_with(field) && line[field.len()..].starts_with(':'));
141 if !has_field {
142 issues.push(format!("MANIFEST.md missing required field: {field}"));
143 }
144 }
145}
146
147fn extract_frontmatter(content: &str) -> Option<String> {
150 let mut lines = content.lines();
151 if lines.next()? != "---" {
152 return None;
153 }
154 let mut body = String::new();
155 for line in lines {
156 if line == "---" {
157 return Some(body);
158 }
159 body.push_str(line);
160 body.push('\n');
161 }
162 None }
164
165fn check_depth(omne: &Path, issues: &mut Vec<String>) {
170 for &subdir in &["cfg", "log"] {
171 let base = omne.join(subdir);
172 if !base.is_dir() {
173 continue; }
175 for entry in WalkDir::new(&base).min_depth(1) {
176 let entry = match entry {
177 Ok(e) => e,
178 Err(_) => continue,
179 };
180 if !entry.file_type().is_dir() {
181 continue;
182 }
183 let rel = match entry.path().strip_prefix(omne) {
186 Ok(r) => r,
187 Err(_) => continue,
188 };
189 let depth = rel.components().count();
190 if depth > MAX_DEPTH {
191 issues.push(format!(
192 "depth violation ({depth} > {MAX_DEPTH}): .omne/{}",
193 rel.display()
194 ));
195 }
196 }
197 }
198}
199
200fn check_gate_runner(omne: &Path, issues: &mut Vec<String>) {
204 let core_manifest = omne.join("core/manifest.json");
205 if !core_manifest.is_file() {
206 return; }
208
209 let content = match std::fs::read_to_string(&core_manifest) {
210 Ok(c) => c,
211 Err(_) => return,
212 };
213
214 let data: serde_json::Value = match serde_json::from_str(&content) {
215 Ok(d) => d,
216 Err(_) => {
217 issues.push("core/manifest.json is invalid JSON".to_string());
218 return;
219 }
220 };
221
222 let gate_runner = match data.get("gate_runner").and_then(|v| v.as_str()) {
223 Some(gr) => gr,
224 None => return, };
226
227 let image_dir = omne.join("image");
229 if !is_safe_gate_runner_path(gate_runner, &image_dir) {
230 issues.push(format!("gate runner path escapes image/: {gate_runner}"));
231 return;
232 }
233
234 let runner_path = image_dir.join(gate_runner);
235 if !runner_path.is_file() {
236 eprintln!("\x1b[33mwarning:\x1b[0m gate runner not found: image/{gate_runner} (skipping)");
239 return;
240 }
241
242 let interpreter = match python::find_interpreter() {
244 Some(interp) => interp,
245 None => {
246 eprintln!("\x1b[33m{}\x1b[0m", python::missing_python_warning());
248 return;
249 }
250 };
251
252 if let Err(e) = python::run_gate_runner(&interpreter, &runner_path, &image_dir) {
254 match e {
255 python::Error::GateRunnerFailed {
256 exit_code,
257 stdout,
258 stderr,
259 } => {
260 let mut msg = format!("gate runner failed (exit {exit_code}):");
261 for line in stdout.trim().lines() {
262 msg.push_str(&format!("\n {line}"));
263 }
264 for line in stderr.trim().lines() {
265 msg.push_str(&format!("\n {line}"));
266 }
267 issues.push(msg);
268 }
269 python::Error::GateRunnerTimedOut { elapsed_seconds } => {
270 issues.push(format!(
271 "gate runner timed out after {elapsed_seconds} seconds"
272 ));
273 }
274 python::Error::InterpreterInvocation(io_err) => {
275 issues.push(format!("failed to invoke gate runner: {io_err}"));
276 }
277 }
278 }
279}
280
281fn is_safe_gate_runner_path(gate_runner: &str, image_dir: &Path) -> bool {
284 let path = Path::new(gate_runner);
285
286 if path.is_absolute() {
288 return false;
289 }
290
291 for component in path.components() {
293 match component {
294 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
295 return false;
296 }
297 _ => {}
298 }
299 }
300
301 let resolved = image_dir.join(path).clean();
303 let image_clean = image_dir.clean();
304 resolved.starts_with(&image_clean)
305}