action_validator/
lib.rs

1mod config;
2mod schemas;
3mod system;
4mod utils;
5mod validation_error;
6mod validation_state;
7
8use config::{ActionType, RunConfig};
9use std::path::PathBuf;
10use validation_error::ValidationError;
11use validation_state::ValidationState;
12
13pub use crate::config::CliConfig;
14use crate::schemas::{validate_as_action, validate_as_workflow};
15
16#[cfg(feature = "js")]
17mod js {
18    use super::cli;
19    use crate::config::CliConfig;
20    use crate::system;
21    use crate::{
22        config::{ActionType, JsConfig},
23        utils::set_panic_hook,
24    };
25    use clap::Parser as _;
26    use js_sys::Array;
27    use wasm_bindgen::prelude::*;
28
29    #[wasm_bindgen(js_name = main)]
30    pub fn main(args: Array) -> JsValue {
31        set_panic_hook();
32
33        let rust_args: Vec<String> = args
34            .iter()
35            .map(|arg| arg.as_string().unwrap_or_default())
36            .collect();
37
38        let config = match CliConfig::try_parse_from(rust_args) {
39            Ok(config) => config,
40            Err(error) => {
41                let error_text = if system::process::stdout::is_tty() {
42                    format!("{}", error.render().ansi())
43                } else {
44                    error.render().to_string()
45                };
46                system::console::error(&error_text);
47                system::process::exit(error.exit_code());
48            }
49        };
50
51        if matches!(cli::run(&config), cli::RunResult::Failure) {
52            system::process::exit(1);
53        }
54
55        system::process::exit(0);
56    }
57
58    #[wasm_bindgen(js_name = validateAction)]
59    pub fn validate_action(src: &str) -> JsValue {
60        set_panic_hook();
61
62        let config = JsConfig {
63            action_type: ActionType::Action,
64            src,
65            verbose: false,
66        };
67
68        run(&config)
69    }
70
71    #[wasm_bindgen(js_name = validateWorkflow)]
72    pub fn validate_workflow(src: &str) -> JsValue {
73        set_panic_hook();
74
75        let config = JsConfig {
76            action_type: ActionType::Workflow,
77            src,
78            verbose: false,
79        };
80
81        run(&config)
82    }
83
84    fn run(config: &JsConfig) -> JsValue {
85        let run_config = config.into();
86        let state = crate::run(&run_config);
87        serde_wasm_bindgen::to_value(&state).unwrap()
88    }
89}
90
91pub mod cli {
92    use crate::{
93        config::{ActionType, RunConfig},
94        system, CliConfig,
95    };
96
97    pub enum RunResult {
98        Success,
99        Failure,
100    }
101
102    pub fn run(config: &CliConfig) -> RunResult {
103        let mut success = true;
104
105        for path in &config.src {
106            let file_name = match path.file_name() {
107                Some(file_name) => file_name.to_str(),
108                None => {
109                    eprintln!("Unable to derive file name from src!");
110                    success = false;
111                    continue;
112                }
113            };
114
115            let src = &match system::fs::read_to_string(path) {
116                Ok(src) => src,
117                Err(err) => {
118                    system::console::error(&format!(
119                        "Unable to read file {}: {err}",
120                        path.display()
121                    ));
122                    success = false;
123                    continue;
124                }
125            };
126
127            let config = RunConfig {
128                file_path: Some(path.to_str().unwrap()),
129                file_name,
130                action_type: match file_name {
131                    Some("action.yml") | Some("action.yaml") => ActionType::Action,
132                    _ => ActionType::Workflow,
133                },
134                src,
135                verbose: config.verbose,
136                rootdir: config.rootdir.clone(),
137            };
138
139            let state = crate::run(&config);
140
141            if !state.is_valid() {
142                let fmt_state = format!("{state:#?}");
143                let path = state.file_path.unwrap_or("file".into());
144                system::console::log(&format!("Fatal error validating {path}"));
145                system::console::error(&format!("Validation failed: {fmt_state}"));
146                success = false;
147            }
148        }
149
150        if success {
151            RunResult::Success
152        } else {
153            RunResult::Failure
154        }
155    }
156}
157
158fn run(config: &RunConfig) -> ValidationState {
159    let file_name = config.file_name.unwrap_or("file");
160    let doc = serde_yaml::from_str(config.src);
161
162    let mut state = match doc {
163        Err(err) => ValidationState {
164            action_type: Some(config.action_type),
165            file_path: Some(file_name.to_string()),
166            errors: vec![err.into()],
167        },
168        Ok(doc) => match config.action_type {
169            ActionType::Action => {
170                if config.verbose {
171                    system::console::log(&format!("Treating {file_name} as an Action definition"));
172                }
173                validate_as_action(&doc)
174            }
175            ActionType::Workflow => {
176                if config.verbose {
177                    system::console::log(&format!("Treating {file_name} as a Workflow definition"));
178                }
179                // TODO: Re-enable path and job validation
180                let mut state = validate_as_workflow(&doc);
181
182                validate_paths(&doc, config.rootdir.as_ref(), &mut state);
183                validate_job_needs(&doc, &mut state);
184
185                state
186            }
187        },
188    };
189
190    state.action_type = Some(config.action_type);
191    state.file_path = config.file_path.map(|file_name| file_name.to_string());
192
193    state
194}
195
196fn validate_paths(doc: &serde_json::Value, rootdir: Option<&PathBuf>, state: &mut ValidationState) {
197    validate_globs(
198        &doc["on"]["push"]["paths"],
199        "/on/push/paths",
200        rootdir,
201        state,
202    );
203    validate_globs(
204        &doc["on"]["push"]["paths-ignore"],
205        "/on/push/paths-ignore",
206        rootdir,
207        state,
208    );
209    validate_globs(
210        &doc["on"]["pull_request"]["paths"],
211        "/on/pull_request/paths",
212        rootdir,
213        state,
214    );
215    validate_globs(
216        &doc["on"]["pull_request"]["paths-ignore"],
217        "/on/pull_request/paths-ignore",
218        rootdir,
219        state,
220    );
221}
222
223#[cfg(feature = "js")]
224fn validate_globs(
225    value: &serde_json::Value,
226    path: &str,
227    _rootdir: Option<&PathBuf>,
228    _: &mut ValidationState,
229) {
230    if !value.is_null() {
231        system::console::warn(&format!(
232            "WARNING: Glob validation is not yet supported. Glob at {path} will not be validated."
233        ));
234    }
235}
236
237#[cfg(not(feature = "js"))]
238fn validate_globs(
239    globs: &serde_json::Value,
240    path: &str,
241    rootdir: Option<&PathBuf>,
242    state: &mut ValidationState,
243) {
244    if globs.is_null() {
245        return;
246    }
247
248    if let Some(globs) = globs.as_array() {
249        for g in globs {
250            let glob = g.as_str().expect("glob to be a string");
251            let pattern = if glob.starts_with('!') {
252                glob.chars().skip(1).collect()
253            } else {
254                glob.to_string()
255            };
256
257            let pattern = if let Some(rootdir) = rootdir {
258                rootdir.join(pattern).display().to_string()
259            } else {
260                pattern
261            };
262
263            match glob::glob(&pattern) {
264                Ok(res) => {
265                    if res.count() == 0 {
266                        state.errors.push(ValidationError::NoFilesMatchingGlob {
267                            code: "glob_not_matched".into(),
268                            path: path.into(),
269                            title: "Glob does not match any files".into(),
270                            detail: Some(format!("Glob {g} in {path} does not match any files")),
271                        });
272                    }
273                }
274                Err(e) => {
275                    state.errors.push(ValidationError::InvalidGlob {
276                        code: "invalid_glob".into(),
277                        path: path.into(),
278                        title: "Glob does not match any files".into(),
279                        detail: Some(format!("Glob {g} in {path} is invalid: {e}")),
280                    });
281                }
282            };
283        }
284    } else {
285        unreachable!(
286            "validate_globs called on globs object with invalid type: must be array or null"
287        )
288    }
289}
290
291fn validate_job_needs(doc: &serde_json::Value, state: &mut ValidationState) {
292    fn is_invalid_dependency(
293        jobs: &serde_json::Map<String, serde_json::Value>,
294        need_str: &str,
295    ) -> bool {
296        !jobs.contains_key(need_str)
297    }
298
299    fn handle_unresolved_job(job_name: &String, needs_str: &str, state: &mut ValidationState) {
300        state.errors.push(ValidationError::UnresolvedJob {
301            code: "unresolved_job".into(),
302            path: format!("/jobs/{job_name}/needs"),
303            title: "Unresolved job".into(),
304            detail: Some(format!("unresolved job {needs_str}")),
305        });
306    }
307
308    if let Some(jobs) = doc["jobs"].as_object() {
309        for (job_name, job) in jobs.iter() {
310            let needs = &job["needs"];
311            if let Some(needs_str) = needs.as_str() {
312                if is_invalid_dependency(jobs, needs_str) {
313                    handle_unresolved_job(job_name, needs_str, state);
314                }
315            } else if let Some(needs_array) = needs.as_array() {
316                for needs_str in needs_array
317                    .iter()
318                    .filter_map(|v| v.as_str())
319                    .filter(|needs_str| is_invalid_dependency(jobs, needs_str))
320                {
321                    handle_unresolved_job(job_name, needs_str, state);
322                }
323            }
324        }
325    }
326}