Skip to main content

document_svg/document/
github_actions.rs

1//! Bounded GitHub Actions workflow previews.
2//!
3//! Workflow YAML is summarized as a job plan. Shell commands, expressions,
4//! environment and secret values, reusable workflows, actions, artifacts and
5//! runner/network operations remain inert; no workflow is dispatched or run.
6
7use 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
20/// Detect a workflow YAML document conservatively from its required `jobs` map.
21pub(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}