1use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::document::html::{HtmlBlock, render_blocks_to_pages};
11use crate::error::{Error, Result};
12use crate::table::{TableAlign, TableData};
13
14const MAX_WORKFLOW_BYTES: u64 = 16 * 1024 * 1024;
15const MAX_WORKFLOW_LINES: usize = 500_000;
16const MAX_WORKFLOW_LINE_BYTES: usize = 1024 * 1024;
17const MAX_WORKFLOW_JOBS: usize = 100_000;
18const MAX_WORKFLOW_STRING_BYTES: usize = 2 * 1024 * 1024;
19
20pub(crate) fn looks_like_yaml_prefix(prefix: &[u8]) -> bool {
22 let Ok(text) = std::str::from_utf8(prefix) else {
23 return false;
24 };
25 let mut jobs = false;
26 let mut job_entry = false;
27 for raw in text.lines() {
28 let trimmed = raw.trim();
29 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---" || trimmed == "..." {
30 continue;
31 }
32 let indent = raw.len() - raw.trim_start().len();
33 let Some((raw_key, _)) = trimmed.split_once(':') else {
34 continue;
35 };
36 let key = clean_key(raw_key);
37 if indent == 0 {
38 jobs = key == "jobs";
39 job_entry = false;
40 } else if jobs && indent == 2 {
41 job_entry = true;
42 } else if jobs
43 && job_entry
44 && indent >= 4
45 && matches!(
46 key.as_str(),
47 "name" | "runs-on" | "needs" | "steps" | "uses" | "strategy" | "if" | "permissions"
48 )
49 {
50 return true;
51 }
52 }
53 false
54}
55
56struct WorkflowPageSink<'a> {
57 inner: &'a mut dyn PageConsumer,
58 warnings: &'a [String],
59}
60
61impl PageConsumer for WorkflowPageSink<'_> {
62 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
63 page.source_format = "github-actions".into();
64 if page.title.is_empty() {
65 page.title = "GitHub Actions workflow".into();
66 }
67 page.description =
68 "GitHub Actions jobs and steps are rendered as inert metadata; no workflow execution or dispatch is performed".into();
69 for warning in self.warnings {
70 page.warn(warning.clone());
71 }
72 self.inner.consume(page)
73 }
74}
75
76pub(crate) fn convert(
77 path: &Path,
78 options: &ConvertOptions,
79 sink: &mut dyn PageConsumer,
80) -> Result<Vec<String>> {
81 let bytes = read_limited_file(
82 path,
83 options.max_input_bytes.min(MAX_WORKFLOW_BYTES),
84 "GitHub Actions workflow input",
85 )?;
86 let text = String::from_utf8(bytes).map_err(|error| {
87 Error::InvalidInput(format!("GitHub Actions workflow must be UTF-8: {error}"))
88 })?;
89 let (table, metadata, warnings) = parse_yaml(&text)?;
90 let blocks = vec![
91 HtmlBlock::Heading {
92 level: 1,
93 text: "GitHub Actions workflow".into(),
94 },
95 HtmlBlock::Paragraph { text: metadata },
96 HtmlBlock::Table(table),
97 ];
98 let mut page_sink = WorkflowPageSink {
99 inner: sink,
100 warnings: &warnings,
101 };
102 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
103 Ok(warnings)
104}
105
106fn parse_yaml(text: &str) -> Result<(TableData, String, Vec<String>)> {
107 if text.len() as u64 > MAX_WORKFLOW_BYTES {
108 return Err(Error::LimitExceeded(format!(
109 "GitHub Actions workflow exceeds {MAX_WORKFLOW_BYTES} bytes"
110 )));
111 }
112 let (_, mut warnings) = crate::document::yaml::parse_yaml_blocks(text)?;
113 let mut parser = WorkflowParser::default();
114 for (line_number, raw) in text.lines().enumerate() {
115 if line_number >= MAX_WORKFLOW_LINES {
116 return Err(Error::LimitExceeded(format!(
117 "GitHub Actions workflow exceeds {MAX_WORKFLOW_LINES} lines"
118 )));
119 }
120 if raw.len() > MAX_WORKFLOW_LINE_BYTES {
121 return Err(Error::LimitExceeded(format!(
122 "GitHub Actions workflow line {} exceeds {MAX_WORKFLOW_LINE_BYTES} bytes",
123 line_number + 1
124 )));
125 }
126 parser.line(raw)?;
127 }
128 parser.finish()?;
129 if parser.rows.is_empty() {
130 return Err(Error::InvalidInput(
131 "GitHub Actions workflow requires a non-empty jobs map".into(),
132 ));
133 }
134 warnings.push("GitHub Actions run/uses commands, expressions, environment and secret values are omitted; actions, workflows, artifacts, runners and network operations are never executed".into());
135 warnings.push("Workflow triggers, permissions, matrix expansion, reusable workflows and expressions are summarized without evaluation".into());
136 let mut metadata = vec![
137 format!("Jobs: {}", parser.rows.len()),
138 format!("Triggers: {}", parser.triggers),
139 ];
140 if !parser.workflow_name.is_empty() {
141 metadata.insert(0, format!("Name: {}", truncate(&parser.workflow_name)));
142 }
143 if parser.top_level_permissions {
144 metadata.push("Top-level permissions: present".into());
145 }
146 Ok((table(parser.rows), metadata.join("\n"), warnings))
147}
148
149#[derive(Default)]
150struct WorkflowParser {
151 in_jobs: bool,
152 top_section: Option<String>,
153 current_field: Option<String>,
154 current: Option<WorkflowJob>,
155 rows: Vec<Vec<String>>,
156 workflow_name: String,
157 triggers: usize,
158 top_level_permissions: bool,
159}
160
161#[derive(Default)]
162struct WorkflowJob {
163 id: String,
164 name: String,
165 runner: String,
166 needs: usize,
167 steps: usize,
168 actions: usize,
169 commands: usize,
170 matrix: usize,
171}
172
173impl WorkflowParser {
174 fn line(&mut self, raw: &str) -> Result<()> {
175 let trimmed = raw.trim();
176 if trimmed.is_empty() || trimmed.starts_with('#') {
177 return Ok(());
178 }
179 if trimmed == "---" || trimmed == "..." {
180 self.flush_job()?;
181 self.in_jobs = false;
182 self.top_section = None;
183 self.current_field = None;
184 return Ok(());
185 }
186 let indent = raw.len() - raw.trim_start().len();
187 if indent == 0 {
188 self.flush_job()?;
189 self.current_field = None;
190 let Some((raw_key, raw_value)) = trimmed.split_once(':') else {
191 self.in_jobs = false;
192 self.top_section = None;
193 return Ok(());
194 };
195 let key = clean_key(raw_key);
196 let value = scalar(raw_value);
197 if key == "name" && !value.is_empty() {
198 self.workflow_name = truncate(&value);
199 }
200 if key == "on" {
201 self.top_section = Some("on".into());
202 self.triggers = self.triggers.saturating_add(inline_count(&value));
203 } else if key == "permissions" {
204 self.top_level_permissions = true;
205 self.top_section = Some("permissions".into());
206 } else {
207 self.top_section = None;
208 }
209 self.in_jobs = key == "jobs";
210 return Ok(());
211 }
212 if self.in_jobs {
213 if indent == 2 {
214 self.flush_job()?;
215 let Some((raw_key, _)) = trimmed.split_once(':') else {
216 self.current_field = None;
217 return Ok(());
218 };
219 self.current = Some(WorkflowJob {
220 id: clean_key(raw_key),
221 ..WorkflowJob::default()
222 });
223 self.current_field = None;
224 return Ok(());
225 }
226 if indent == 4 {
227 let Some((raw_key, raw_value)) = trimmed.split_once(':') else {
228 return Ok(());
229 };
230 let key = clean_key(raw_key);
231 let value = scalar(raw_value);
232 self.current_field = Some(key.clone());
233 if let Some(job) = self.current.as_mut() {
234 match key.as_str() {
235 "name" => job.name = truncate(&value),
236 "runs-on" => job.runner = truncate(&value),
237 "needs" => job.needs += inline_count(&value),
238 "steps" => job.steps += inline_count(&value),
239 "strategy" => job.matrix += inline_count(&value),
240 _ => {}
241 }
242 }
243 return Ok(());
244 }
245 if indent == 6
246 && let Some(field) = self.current_field.as_deref()
247 && let Some(job) = self.current.as_mut()
248 {
249 if field == "steps" && trimmed.starts_with('-') {
250 job.steps = job.steps.saturating_add(1);
251 if trimmed.contains("uses:") {
252 job.actions = job.actions.saturating_add(1);
253 }
254 if trimmed.contains("run:") {
255 job.commands = job.commands.saturating_add(1);
256 }
257 } else if field == "needs" && (trimmed.starts_with('-') || trimmed.contains(':')) {
258 job.needs = job.needs.saturating_add(1);
259 } else if field == "strategy"
260 && clean_key(trimmed.split_once(':').map_or(trimmed, |(key, _)| key))
261 == "matrix"
262 {
263 job.matrix = job.matrix.saturating_add(1);
264 }
265 } else if indent >= 8
266 && let Some(field) = self.current_field.as_deref()
267 && field == "steps"
268 && let Some(job) = self.current.as_mut()
269 {
270 let key = trimmed
271 .split_once(':')
272 .map_or_else(String::new, |(key, _)| clean_key(key));
273 if key == "uses" {
274 job.actions = job.actions.saturating_add(1);
275 } else if key == "run" {
276 job.commands = job.commands.saturating_add(1);
277 }
278 }
279 return Ok(());
280 }
281 if self.top_section.as_deref() == Some("on")
282 && indent == 2
283 && (trimmed.split_once(':').is_some() || trimmed.starts_with('-'))
284 {
285 self.triggers = self.triggers.saturating_add(1);
286 }
287 Ok(())
288 }
289
290 fn flush_job(&mut self) -> Result<()> {
291 let Some(job) = self.current.take() else {
292 return Ok(());
293 };
294 if self.rows.len() >= MAX_WORKFLOW_JOBS {
295 return Err(Error::LimitExceeded(format!(
296 "GitHub Actions jobs exceed {MAX_WORKFLOW_JOBS}"
297 )));
298 }
299 let label = if job.name.is_empty() {
300 job.id.clone()
301 } else {
302 format!("{} ({})", job.id, job.name)
303 };
304 let runner = if job.runner.is_empty() {
305 "—".into()
306 } else {
307 job.runner
308 };
309 let execution = if job.matrix == 0 {
310 format!("A:{} R:{}", job.actions, job.commands)
311 } else {
312 format!("A:{} R:{} M", job.actions, job.commands)
313 };
314 self.rows.push(vec![
315 truncate(&label),
316 truncate(&runner),
317 format!("N:{} S:{}", job.needs, job.steps),
318 execution,
319 ]);
320 Ok(())
321 }
322
323 fn finish(&mut self) -> Result<()> {
324 self.flush_job()
325 }
326}
327
328fn table(rows: Vec<Vec<String>>) -> TableData {
329 TableData {
330 headers: vec![
331 "Job".into(),
332 "Runner".into(),
333 "Flow".into(),
334 "Execution".into(),
335 ],
336 rows,
337 alignments: vec![TableAlign::Left; 4],
338 raw_source: String::new(),
339 }
340}
341
342fn clean_key(raw: &str) -> String {
343 raw.trim().trim_matches(['"', '\'']).to_owned()
344}
345
346fn scalar(raw: &str) -> String {
347 raw.trim()
348 .split_once(" #")
349 .map_or(raw.trim(), |(value, _)| value.trim())
350 .trim_matches(['"', '\''])
351 .to_owned()
352}
353
354fn inline_count(value: &str) -> usize {
355 let value = value.trim();
356 if value.is_empty() || value == "{}" || value == "[]" || value == "null" {
357 return 0;
358 }
359 if value.starts_with('[') {
360 return value
361 .trim_matches(['[', ']'])
362 .split(',')
363 .filter(|part| !part.trim().is_empty())
364 .count();
365 }
366 if value.starts_with('{') {
367 return value
368 .trim_matches(['{', '}'])
369 .split(',')
370 .filter(|part| part.split_once(':').is_some())
371 .count();
372 }
373 if value.starts_with('-') { 1 } else { 0 }
374}
375
376fn truncate(value: &str) -> String {
377 if value.len() <= MAX_WORKFLOW_STRING_BYTES {
378 return value.to_owned();
379 }
380 let mut end = MAX_WORKFLOW_STRING_BYTES;
381 while !value.is_char_boundary(end) {
382 end -= 1;
383 }
384 format!("{}…", &value[..end])
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn recognizes_workflow_yaml() {
393 assert!(looks_like_yaml_prefix(
394 b"name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: cargo test\n"
395 ));
396 assert!(!looks_like_yaml_prefix(
397 b"services:\n web:\n image: nginx\n"
398 ));
399 }
400
401 #[test]
402 fn omits_workflow_commands_and_counts_steps() {
403 let (table, metadata, warnings) = parse_yaml(
404 "name: CI\non: [push, pull_request]\njobs:\n build:\n runs-on: ubuntu-latest\n needs: [lint]\n steps:\n - uses: actions/checkout@v4\n - run: echo very-secret\n",
405 )
406 .unwrap();
407 assert!(metadata.contains("Jobs: 1"));
408 assert_eq!(table.rows[0][2], "N:1 S:2");
409 assert_eq!(table.rows[0][3], "A:1 R:1");
410 assert!(
411 !table
412 .rows
413 .iter()
414 .flatten()
415 .any(|value| value.contains("very-secret"))
416 );
417 assert!(warnings.iter().any(|warning| warning.contains("never")));
418 }
419}