Skip to main content

omne_cli/commands/
validate.rs

1//! `omne validate` — check volume integrity and run the distro gate runner.
2//!
3//! Walks up from cwd to find `.omne/`, runs integrity checks (required
4//! dirs, core manifest, image contents, MANIFEST.md fields, depth rule
5//! scoped to `cfg/`/`log/`), and optionally invokes the gate runner with
6//! graceful Python degradation (R15).
7
8use 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
18/// Maximum nesting depth for user-authored content under `cfg/` and `log/`.
19/// Measured as path components relative to `.omne/` (includes the `cfg/`
20/// or `log/` prefix). e.g. `cfg/sub1/sub2` = depth 3, allowed.
21/// Fixes the Python bug where MAX_DEPTH was 2 instead of 3 (R3).
22const MAX_DEPTH: usize = 3;
23
24/// Required top-level directories under `.omne/`.
25const REQUIRED_DIRS: &[&str] = &["core", "image", "cfg", "log"];
26
27/// Required directories under `.omne/image/`.
28const REQUIRED_IMAGE_DIRS: &[&str] = &["agents", "skills", "hooks"];
29
30/// Required files under `.omne/image/`.
31const REQUIRED_IMAGE_FILES: &[&str] = &["context-map.md", "SYSTEM.md"];
32
33/// Required fields in MANIFEST.md frontmatter. Extends the Python list
34/// with `kernel-source` and `distro-source`.
35const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
36    "volume",
37    "distro",
38    "created",
39    "kernel-source",
40    "distro-source",
41];
42
43/// Arguments for `omne validate`. None today; kept as an explicit struct
44/// so adding flags later is mechanical.
45#[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
54/// Test seam: validate a volume rooted at (or walked up from) `start`.
55pub 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
75/// Check that all required top-level directories exist under `.omne/`.
76fn 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
84/// Check that `.omne/core/manifest.json` exists.
85fn check_core(omne: &Path, issues: &mut Vec<String>) {
86    let core = omne.join("core");
87    if !core.is_dir() {
88        return; // already caught by check_required_dirs
89    }
90    if !core.join("manifest.json").is_file() {
91        issues.push("missing kernel manifest: core/manifest.json".to_string());
92    }
93}
94
95/// Check that `.omne/image/` has required subdirectories and files.
96fn check_image(omne: &Path, issues: &mut Vec<String>) {
97    let image = omne.join("image");
98    if !image.is_dir() {
99        return; // already caught by check_required_dirs
100    }
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
113/// Check that MANIFEST.md exists, has frontmatter, and contains all required fields.
114fn 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    // Extract frontmatter block
130    let Some(yaml_body) = extract_frontmatter(&content) else {
131        issues.push("MANIFEST.md has no YAML frontmatter (---...---)".to_string());
132        return;
133    };
134
135    // Check each required field via line-by-line scan (matching Python's
136    // regex approach — looks for `field:` at start of line)
137    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
147/// Extract the YAML body between `---` fences. Returns `None` if no valid
148/// frontmatter block is found.
149fn 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 // unclosed fence
163}
164
165/// Check depth of directories under `cfg/` and `log/` only.
166/// MAX_DEPTH = 3 (fixes Python bug). Scoped to user-authored content:
167/// `core/` and `image/` are excluded since they contain distro/kernel
168/// release artifacts that may have deep nesting.
169fn 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; // already caught by check_required_dirs
174        }
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            // Depth is measured from .omne/, so include the subdir prefix.
184            // e.g. cfg/sub1/sub2/sub3 → 4 components from .omne/.
185            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
200/// Read gate_runner from core/manifest.json, validate path safety, and
201/// invoke it with the discovered Python interpreter. Graceful degradation
202/// when Python is absent (R15).
203fn 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; // no kernel manifest, skip
207    }
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, // no gate runner defined, skip
225    };
226
227    // Path traversal safety check
228    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        // Per man/gate-protocol.md: "If no file exists at the gate
237        // runner path, the step is skipped with a warning."
238        eprintln!("\x1b[33mwarning:\x1b[0m gate runner not found: image/{gate_runner} (skipping)");
239        return;
240    }
241
242    // Find Python interpreter
243    let interpreter = match python::find_interpreter() {
244        Some(interp) => interp,
245        None => {
246            // R15: graceful degradation — warn but don't add an issue
247            eprintln!("\x1b[33m{}\x1b[0m", python::missing_python_warning());
248            return;
249        }
250    };
251
252    // Run the gate runner
253    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
281/// Check that a gate runner path is safe (no traversal, no absolute paths).
282/// Reuses the same component-filtering logic as `tarball::extract_safe`.
283fn is_safe_gate_runner_path(gate_runner: &str, image_dir: &Path) -> bool {
284    let path = Path::new(gate_runner);
285
286    // Reject absolute paths
287    if path.is_absolute() {
288        return false;
289    }
290
291    // Reject any component that is `..`, root, or prefix
292    for component in path.components() {
293        match component {
294            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
295                return false;
296            }
297            _ => {}
298        }
299    }
300
301    // Final check: resolved path must stay under image_dir
302    let resolved = image_dir.join(path).clean();
303    let image_clean = image_dir.clean();
304    resolved.starts_with(&image_clean)
305}