Skip to main content

gha_command_proof/
command.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::Serialize;
4
5use crate::receipt::{Check, Location};
6
7const MODERN_KEY: &str = "::";
8const LEGACY_PREFIX: &str = "##[";
9
10const KNOWN_COMMANDS: &[&str] = &[
11    "add-mask",
12    "add-matcher",
13    "add-path",
14    "debug",
15    "echo",
16    "endgroup",
17    "error",
18    "group",
19    "notice",
20    "remove-matcher",
21    "save-state",
22    "set-env",
23    "set-output",
24    "stop-commands",
25    "warning",
26];
27
28const UNSUPPORTED_COMMANDS: &[&str] = &["set-env", "add-path"];
29const DEPRECATED_COMMANDS: &[&str] = &["set-output", "save-state"];
30
31#[derive(Clone, Debug)]
32pub struct LogAnalysis {
33    pub checks: Vec<Check>,
34    pub commands: Vec<CommandRecord>,
35    pub redacted_log: String,
36    pub mask_values: Vec<String>,
37}
38
39#[derive(Clone, Debug, Serialize)]
40pub struct CommandRecord {
41    pub line: usize,
42    pub syntax: CommandSyntax,
43    pub name: String,
44    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
45    pub properties: BTreeMap<String, String>,
46    #[serde(skip_serializing_if = "String::is_empty")]
47    pub data: String,
48    pub processed: bool,
49    pub outcome: String,
50}
51
52#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
53#[serde(rename_all = "kebab-case")]
54pub enum CommandSyntax {
55    Modern,
56    Legacy,
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct ParsedCommand {
61    pub line: usize,
62    pub syntax: CommandSyntax,
63    pub name: String,
64    pub properties: BTreeMap<String, String>,
65    pub data: String,
66}
67
68#[derive(Default)]
69struct CommandStats {
70    parsed: usize,
71    modern: usize,
72    legacy: usize,
73    unknown: usize,
74    unsupported: usize,
75    deprecated: usize,
76    masks: usize,
77    suppressed: usize,
78    annotations: usize,
79}
80
81#[derive(Default)]
82struct MaskSet {
83    values: Vec<String>,
84}
85
86impl MaskSet {
87    fn add(&mut self, value: &str) {
88        for candidate in mask_candidates(value) {
89            if !candidate.is_empty() && !self.values.iter().any(|known| known == &candidate) {
90                self.values.push(candidate);
91            }
92        }
93        self.values
94            .sort_by_key(|value| std::cmp::Reverse(value.len()));
95    }
96
97    fn mask(&self, value: &str) -> String {
98        let mut redacted = value.to_string();
99        for secret in &self.values {
100            redacted = redacted.replace(secret, "***");
101        }
102        redacted
103    }
104
105    fn contains_exact(&self, value: &str) -> bool {
106        !value.is_empty() && self.values.iter().any(|secret| secret == value)
107    }
108}
109
110pub fn analyze_command_stream(text: &str, source: Option<String>) -> LogAnalysis {
111    let mut checks = Vec::new();
112    let mut commands = Vec::new();
113    let mut redacted_lines = Vec::new();
114    let mut masks = MaskSet::default();
115    let mut stats = CommandStats::default();
116    let mut group_stack: Vec<(usize, String)> = Vec::new();
117    let mut stopped: Option<(String, usize)> = None;
118
119    for (index, line) in text.lines().enumerate() {
120        let line_number = index + 1;
121        let parsed = parse_command_line(line, line_number);
122        if let Some(command) = parsed.as_ref() {
123            stats.parsed += 1;
124            match command.syntax {
125                CommandSyntax::Modern => stats.modern += 1,
126                CommandSyntax::Legacy => stats.legacy += 1,
127            }
128        }
129
130        if let Some((token, _)) = stopped.clone() {
131            if let Some(command) = parsed.as_ref()
132                && command.syntax == CommandSyntax::Modern
133                && command.name.eq_ignore_ascii_case(&token)
134            {
135                commands.push(record_from_command(command, true, "resumed", &masks));
136                stopped = None;
137                redacted_lines.push(masks.mask(line));
138                continue;
139            }
140
141            if let Some(command) = parsed.as_ref() {
142                stats.suppressed += 1;
143                commands.push(record_from_command(
144                    command,
145                    false,
146                    "suppressed by stop-commands",
147                    &masks,
148                ));
149            }
150            redacted_lines.push(masks.mask(line));
151            continue;
152        }
153
154        let Some(command) = parsed else {
155            redacted_lines.push(masks.mask(line));
156            continue;
157        };
158
159        validate_command(
160            &command,
161            &source,
162            &mut checks,
163            &mut stats,
164            &mut masks,
165            &mut group_stack,
166            &mut stopped,
167        );
168
169        let outcome = command_outcome(&command, stopped.as_ref().map(|(_, line)| *line));
170        commands.push(record_from_command(&command, true, outcome, &masks));
171        redacted_lines.push(masks.mask(line));
172    }
173
174    if let Some((token, line)) = stopped {
175        checks.push(
176            Check::fail(
177                "commands.stop_commands.unclosed",
178                "workflow commands were stopped and never resumed",
179                Some(Location::line(source.as_deref(), line)),
180            )
181            .with_detail("token", redact_token(&token)),
182        );
183    }
184
185    for (line, title) in group_stack {
186        checks.push(
187            Check::warn(
188                "commands.group.unclosed",
189                "group command was not closed by an endgroup command",
190                Some(Location::line(source.as_deref(), line)),
191            )
192            .with_detail("title", title),
193        );
194    }
195
196    add_summary_checks(&mut checks, &stats, source.as_deref());
197
198    LogAnalysis {
199        checks,
200        commands,
201        redacted_log: redacted_lines.join("\n"),
202        mask_values: masks.values,
203    }
204}
205
206fn validate_command(
207    command: &ParsedCommand,
208    source: &Option<String>,
209    checks: &mut Vec<Check>,
210    stats: &mut CommandStats,
211    masks: &mut MaskSet,
212    group_stack: &mut Vec<(usize, String)>,
213    stopped: &mut Option<(String, usize)>,
214) {
215    let location = Some(Location::line(source.as_deref(), command.line));
216    let name = command.name.as_str();
217
218    if !is_known_command(name) {
219        stats.unknown += 1;
220        checks.push(Check::warn(
221            "commands.unknown",
222            format!("unknown workflow command `{name}` will be ignored by GitHub runners"),
223            location.clone(),
224        ));
225        return;
226    }
227
228    if UNSUPPORTED_COMMANDS.contains(&name) {
229        stats.unsupported += 1;
230        checks.push(Check::fail(
231            "commands.unsupported",
232            format!("`{name}` is disabled on current GitHub runners; use environment files"),
233            location.clone(),
234        ));
235    }
236
237    if DEPRECATED_COMMANDS.contains(&name) {
238        stats.deprecated += 1;
239        checks.push(Check::warn(
240            "commands.deprecated",
241            format!("`{name}` is deprecated; use `GITHUB_OUTPUT` or `GITHUB_STATE`"),
242            location.clone(),
243        ));
244    }
245
246    match name {
247        "add-mask" => {
248            if command.data.trim().is_empty() {
249                checks.push(Check::warn(
250                    "commands.add_mask.empty",
251                    "`add-mask` with an empty value does not register a mask",
252                    location,
253                ));
254            } else {
255                stats.masks += 1;
256                masks.add(&command.data);
257            }
258        }
259        "stop-commands" => validate_stop_commands(command, source, checks, stopped),
260        "group" => group_stack.push((command.line, masks.mask(&command.data))),
261        "endgroup" if group_stack.pop().is_none() => {
262            checks.push(Check::warn(
263                "commands.group.unmatched_endgroup",
264                "`endgroup` appeared before any open `group` command",
265                location,
266            ));
267        }
268        "endgroup" => {}
269        "echo" => {
270            let value = command.data.trim();
271            if !value.eq_ignore_ascii_case("on") && !value.eq_ignore_ascii_case("off") {
272                checks.push(Check::fail(
273                    "commands.echo.invalid",
274                    "`echo` command value must be `on` or `off`",
275                    location,
276                ));
277            }
278        }
279        "debug" | "notice" | "warning" | "error" => {
280            if matches!(name, "notice" | "warning" | "error") {
281                stats.annotations += 1;
282                validate_annotation(command, source, checks);
283            }
284        }
285        "set-output" | "save-state" | "set-env" if missing_property(command, "name") => {
286            checks.push(Check::fail(
287                format!("commands.{name}.name"),
288                format!("`{name}` requires a non-empty `name` property"),
289                location,
290            ));
291        }
292        "set-output" | "save-state" | "set-env" => {}
293        "add-matcher" if command.data.trim().is_empty() => {
294            checks.push(Check::warn(
295                "commands.add_matcher.path",
296                "`add-matcher` should include a problem matcher file path",
297                location,
298            ));
299        }
300        "add-matcher" => {}
301        "remove-matcher" => {
302            let has_owner = !command
303                .properties
304                .get("owner")
305                .is_none_or(|value| value.trim().is_empty());
306            let has_file = !command.data.trim().is_empty();
307            if has_owner == has_file {
308                checks.push(Check::warn(
309                    "commands.remove_matcher.selector",
310                    "`remove-matcher` should set exactly one of `owner` property or data file path",
311                    location,
312                ));
313            }
314        }
315        "add-path" if command.data.trim().is_empty() => {
316            checks.push(Check::fail(
317                "commands.add_path.path",
318                "`add-path` requires a non-empty path",
319                location,
320            ));
321        }
322        "add-path" => {}
323        _ => {}
324    }
325}
326
327fn validate_stop_commands(
328    command: &ParsedCommand,
329    source: &Option<String>,
330    checks: &mut Vec<Check>,
331    stopped: &mut Option<(String, usize)>,
332) {
333    let token = command.data.trim();
334    let location = Some(Location::line(source.as_deref(), command.line));
335    if token.is_empty() {
336        checks.push(Check::fail(
337            "commands.stop_commands.token",
338            "`stop-commands` requires a non-empty resume token",
339            location,
340        ));
341        return;
342    }
343    if token.eq_ignore_ascii_case("pause-logging") || is_known_command(token) {
344        checks.push(Check::fail(
345            "commands.stop_commands.token",
346            "`stop-commands` token collides with a registered runner command",
347            location.clone(),
348        ));
349    } else if token.len() < 16 {
350        checks.push(Check::warn(
351            "commands.stop_commands.token_entropy",
352            "`stop-commands` token should be random and unique for each run",
353            location.clone(),
354        ));
355    }
356    *stopped = Some((token.to_string(), command.line));
357}
358
359fn validate_annotation(command: &ParsedCommand, source: &Option<String>, checks: &mut Vec<Check>) {
360    let location = Some(Location::line(source.as_deref(), command.line));
361    for key in ["line", "endline", "col", "endcolumn"] {
362        if let Some(value) = command.properties.get(key)
363            && value.parse::<u64>().ok().is_none_or(|value| value == 0)
364        {
365            checks.push(Check::warn(
366                "commands.annotation.position",
367                format!("annotation property `{key}` should be a positive integer"),
368                location.clone(),
369            ));
370        }
371    }
372
373    let line = parse_u64(command.properties.get("line"));
374    let end_line = parse_u64(command.properties.get("endline"));
375    let col = parse_u64(command.properties.get("col"));
376    let end_col = parse_u64(command.properties.get("endcolumn"));
377
378    if end_line.is_some() && line.is_none() {
379        checks.push(Check::warn(
380            "commands.annotation.end_line_without_line",
381            "`endLine` only has an effect when `line` is also set",
382            location.clone(),
383        ));
384    }
385    if (col.is_some() || end_col.is_some()) && line.is_none() {
386        checks.push(Check::warn(
387            "commands.annotation.column_without_line",
388            "`col` and `endColumn` only have an effect when `line` is set",
389            location.clone(),
390        ));
391    }
392    if let (Some(line), Some(end_line)) = (line, end_line)
393        && end_line < line
394    {
395        checks.push(Check::warn(
396            "commands.annotation.end_line_order",
397            "`endLine` should not be less than `line`",
398            location.clone(),
399        ));
400    }
401    if let (Some(line), Some(end_line), Some(_)) = (line, end_line, col.or(end_col))
402        && end_line != line
403    {
404        checks.push(Check::warn(
405            "commands.annotation.column_multiline",
406            "`col` and `endColumn` are ignored when `line` and `endLine` differ",
407            location.clone(),
408        ));
409    }
410    if let (Some(col), Some(end_col)) = (col, end_col)
411        && end_col < col
412    {
413        checks.push(Check::warn(
414            "commands.annotation.end_column_order",
415            "`endColumn` should not be less than `col`",
416            location,
417        ));
418    }
419}
420
421fn add_summary_checks(checks: &mut Vec<Check>, stats: &CommandStats, source: Option<&str>) {
422    let location = source.map(|source| Location::new(Some(source.to_string()), None));
423
424    if stats.parsed == 0 {
425        checks.push(Check::skip(
426            "commands.parse",
427            "no workflow commands found in the stream",
428            location.clone(),
429        ));
430    } else {
431        checks.push(
432            Check::pass(
433                "commands.parse",
434                format!("parsed {} workflow commands", stats.parsed),
435                location.clone(),
436            )
437            .with_detail("modern", stats.modern.to_string())
438            .with_detail("legacy", stats.legacy.to_string()),
439        );
440    }
441
442    if stats.legacy == 0 {
443        checks.push(Check::pass(
444            "commands.syntax",
445            "no legacy `##[...]` commands found",
446            location.clone(),
447        ));
448    } else {
449        checks.push(Check::warn(
450            "commands.syntax.legacy",
451            format!(
452                "found {} legacy `##[...]` commands; prefer modern `::...::` commands",
453                stats.legacy
454            ),
455            location.clone(),
456        ));
457    }
458
459    if stats.unknown == 0 {
460        checks.push(Check::pass(
461            "commands.known",
462            "all processed workflow command names are known runner commands",
463            location.clone(),
464        ));
465    }
466
467    if stats.unsupported == 0 {
468        checks.push(Check::pass(
469            "commands.unsupported",
470            "no disabled stdout commands found",
471            location.clone(),
472        ));
473    }
474
475    if stats.deprecated == 0 {
476        checks.push(Check::pass(
477            "commands.deprecated",
478            "no deprecated stdout state/output commands found",
479            location.clone(),
480        ));
481    }
482
483    if stats.suppressed > 0 {
484        checks.push(Check::pass(
485            "commands.stop_commands.suppressed",
486            format!(
487                "{} apparent workflow commands were safely suppressed while commands were stopped",
488                stats.suppressed
489            ),
490            location.clone(),
491        ));
492    }
493
494    if stats.masks > 0 {
495        checks.push(Check::pass(
496            "commands.add_mask",
497            format!("registered {} mask commands", stats.masks),
498            location.clone(),
499        ));
500    }
501
502    if stats.annotations > 0 {
503        checks.push(Check::pass(
504            "commands.annotations",
505            format!("validated {} annotation commands", stats.annotations),
506            location,
507        ));
508    }
509}
510
511pub fn parse_command_line(line: &str, line_number: usize) -> Option<ParsedCommand> {
512    parse_modern(line, line_number).or_else(|| parse_legacy(line, line_number))
513}
514
515fn parse_modern(line: &str, line_number: usize) -> Option<ParsedCommand> {
516    let message = line.trim_start();
517    let body = message.strip_prefix(MODERN_KEY)?;
518    let separator = body.find(MODERN_KEY)?;
519    let command_info = &body[..separator];
520    let raw_data = &body[separator + MODERN_KEY.len()..];
521    let (raw_name, raw_properties) = split_command_info(command_info, ' ');
522    let name = normalize_name(raw_name)?;
523    let properties = parse_properties(raw_properties, ',', unescape_property);
524
525    Some(ParsedCommand {
526        line: line_number,
527        syntax: CommandSyntax::Modern,
528        name,
529        properties,
530        data: unescape_data(raw_data),
531    })
532}
533
534fn parse_legacy(line: &str, line_number: usize) -> Option<ParsedCommand> {
535    let prefix = line.find(LEGACY_PREFIX)?;
536    let body = &line[prefix + LEGACY_PREFIX.len()..];
537    let rb_index = body.find(']')?;
538    let command_info = &body[..rb_index];
539    let raw_data = &body[rb_index + 1..];
540    let (raw_name, raw_properties) = split_command_info(command_info, ' ');
541    let name = normalize_name(raw_name)?;
542    let properties = parse_properties(raw_properties, ';', unescape_legacy);
543
544    Some(ParsedCommand {
545        line: line_number,
546        syntax: CommandSyntax::Legacy,
547        name,
548        properties,
549        data: unescape_legacy(raw_data),
550    })
551}
552
553fn split_command_info(command_info: &str, delimiter: char) -> (&str, Option<&str>) {
554    if let Some(index) = command_info.find(delimiter) {
555        (
556            &command_info[..index],
557            Some(command_info[index + 1..].trim()),
558        )
559    } else {
560        (command_info, None)
561    }
562}
563
564fn normalize_name(name: &str) -> Option<String> {
565    let name = name.trim();
566    if name.is_empty() {
567        None
568    } else {
569        Some(name.to_ascii_lowercase())
570    }
571}
572
573fn parse_properties(
574    raw_properties: Option<&str>,
575    delimiter: char,
576    unescape: fn(&str) -> String,
577) -> BTreeMap<String, String> {
578    let mut properties = BTreeMap::new();
579    let Some(raw_properties) = raw_properties else {
580        return properties;
581    };
582
583    for property in raw_properties
584        .split(delimiter)
585        .filter(|part| !part.is_empty())
586    {
587        let mut parts = property.splitn(2, '=');
588        let Some(key) = parts.next().map(str::trim).filter(|key| !key.is_empty()) else {
589            continue;
590        };
591        let Some(value) = parts.next() else {
592            continue;
593        };
594        properties.insert(key.to_ascii_lowercase(), unescape(value));
595    }
596
597    properties
598}
599
600pub fn escape_data(value: &str) -> String {
601    value
602        .replace('%', "%25")
603        .replace('\r', "%0D")
604        .replace('\n', "%0A")
605}
606
607pub fn escape_property(value: &str) -> String {
608    escape_data(value).replace(':', "%3A").replace(',', "%2C")
609}
610
611pub fn escape_legacy(value: &str) -> String {
612    value
613        .replace('%', "%25")
614        .replace(';', "%3B")
615        .replace('\r', "%0D")
616        .replace('\n', "%0A")
617        .replace(']', "%5D")
618}
619
620pub fn unescape_data(value: &str) -> String {
621    value
622        .replace("%0D", "\r")
623        .replace("%0A", "\n")
624        .replace("%25", "%")
625}
626
627pub fn unescape_property(value: &str) -> String {
628    value
629        .replace("%0D", "\r")
630        .replace("%0A", "\n")
631        .replace("%3A", ":")
632        .replace("%2C", ",")
633        .replace("%25", "%")
634}
635
636pub fn unescape_legacy(value: &str) -> String {
637    value
638        .replace("%3B", ";")
639        .replace("%0D", "\r")
640        .replace("%0A", "\n")
641        .replace("%5D", "]")
642        .replace("%25", "%")
643}
644
645fn record_from_command(
646    command: &ParsedCommand,
647    processed: bool,
648    outcome: impl Into<String>,
649    masks: &MaskSet,
650) -> CommandRecord {
651    let data = if command.name == "add-mask" {
652        "***".to_string()
653    } else {
654        masks.mask(&command.data)
655    };
656
657    CommandRecord {
658        line: command.line,
659        syntax: command.syntax,
660        name: command.name.clone(),
661        properties: command.properties.clone(),
662        data,
663        processed,
664        outcome: outcome.into(),
665    }
666}
667
668fn command_outcome(command: &ParsedCommand, stopped_line: Option<usize>) -> &'static str {
669    if command.name == "stop-commands" && stopped_line == Some(command.line) {
670        "commands stopped"
671    } else if UNSUPPORTED_COMMANDS.contains(&command.name.as_str()) {
672        "disabled command"
673    } else if DEPRECATED_COMMANDS.contains(&command.name.as_str()) {
674        "deprecated command"
675    } else if is_known_command(&command.name) {
676        "processed"
677    } else {
678        "unknown command"
679    }
680}
681
682fn is_known_command(name: &str) -> bool {
683    KNOWN_COMMANDS
684        .iter()
685        .any(|known| known.eq_ignore_ascii_case(name))
686}
687
688fn missing_property(command: &ParsedCommand, key: &str) -> bool {
689    command
690        .properties
691        .get(key)
692        .is_none_or(|value| value.trim().is_empty())
693}
694
695fn parse_u64(value: Option<&String>) -> Option<u64> {
696    value
697        .and_then(|value| value.parse::<u64>().ok())
698        .filter(|value| *value > 0)
699}
700
701fn mask_candidates(value: &str) -> Vec<String> {
702    let mut candidates = BTreeSet::new();
703    let trimmed = value.trim_matches(['\r', '\n']);
704    if !trimmed.is_empty() {
705        candidates.insert(trimmed.to_string());
706    }
707    for line in value.lines().map(str::trim).filter(|line| !line.is_empty()) {
708        candidates.insert(line.to_string());
709    }
710    for word in value.split_whitespace().filter(|word| !word.is_empty()) {
711        candidates.insert(word.to_string());
712    }
713    candidates.into_iter().collect()
714}
715
716fn redact_token(token: &str) -> String {
717    let chars = token.chars().collect::<Vec<_>>();
718    if chars.len() <= 4 {
719        "***".to_string()
720    } else {
721        let prefix = chars.iter().take(2).collect::<String>();
722        let suffix = chars
723            .iter()
724            .skip(chars.len().saturating_sub(2))
725            .collect::<String>();
726        format!("{prefix}***{suffix}")
727    }
728}
729
730pub(crate) fn redact_with_masks(value: &str, masks: &[String]) -> String {
731    let mask_set = MaskSet {
732        values: masks.to_vec(),
733    };
734    mask_set.mask(value)
735}
736
737pub(crate) fn masks_contain_exact(masks: &[String], value: &str) -> bool {
738    let mask_set = MaskSet {
739        values: masks.to_vec(),
740    };
741    mask_set.contains_exact(value)
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    #[test]
749    fn parses_modern_command_with_escaped_properties() {
750        let parsed =
751            parse_command_line("::warning file=src%2Clib.rs,line=2::hello%0Aworld", 7).unwrap();
752        assert_eq!(parsed.line, 7);
753        assert_eq!(parsed.syntax, CommandSyntax::Modern);
754        assert_eq!(parsed.name, "warning");
755        assert_eq!(parsed.properties["file"], "src,lib.rs");
756        assert_eq!(parsed.data, "hello\nworld");
757    }
758
759    #[test]
760    fn parses_legacy_command_anywhere_in_line() {
761        let parsed = parse_command_line("prefix ##[error file=src/main.rs;line=3]bad%5D", 1)
762            .expect("legacy command parses");
763        assert_eq!(parsed.syntax, CommandSyntax::Legacy);
764        assert_eq!(parsed.name, "error");
765        assert_eq!(parsed.properties["file"], "src/main.rs");
766        assert_eq!(parsed.data, "bad]");
767    }
768
769    #[test]
770    fn redacts_add_mask_value_and_later_log_lines() {
771        let analysis =
772            analyze_command_stream("::add-mask::s3cr3t\nplain s3cr3t\n::warning::s3cr3t", None);
773        assert!(analysis.redacted_log.contains("plain ***"));
774        assert!(!analysis.redacted_log.contains("s3cr3t"));
775        assert_eq!(analysis.commands[0].data, "***");
776        assert_eq!(analysis.commands[1].data, "***");
777    }
778
779    #[test]
780    fn stop_commands_suppresses_commands_until_resume_token() {
781        let analysis = analyze_command_stream(
782            "::stop-commands::token-123456789\n::error::ignored\n::token-123456789::\n::warning::real",
783            None,
784        );
785        let suppressed = analysis
786            .commands
787            .iter()
788            .find(|command| command.name == "error")
789            .expect("suppressed command was recorded");
790        assert!(!suppressed.processed);
791        assert_eq!(suppressed.outcome, "suppressed by stop-commands");
792        assert!(
793            analysis
794                .commands
795                .iter()
796                .any(|command| command.name == "warning")
797        );
798    }
799
800    #[test]
801    fn flags_unsupported_and_deprecated_commands() {
802        let analysis = analyze_command_stream(
803            "::set-env name=FOO::bar\n::set-output name=result::ok",
804            Some("log.txt".to_string()),
805        );
806        assert!(
807            analysis
808                .checks
809                .iter()
810                .any(|check| check.id == "commands.unsupported")
811        );
812        assert!(
813            analysis
814                .checks
815                .iter()
816                .any(|check| check.id == "commands.deprecated")
817        );
818    }
819
820    #[test]
821    fn escape_round_trip_uses_runner_mappings() {
822        let data = "percent %\r\n";
823        assert_eq!(unescape_data(&escape_data(data)), data);
824        let property = "a:b,c%\r\n";
825        assert_eq!(unescape_property(&escape_property(property)), property);
826        let legacy = "a;b]\r\n%";
827        assert_eq!(unescape_legacy(&escape_legacy(legacy)), legacy);
828    }
829}