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}