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 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}