1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
//! A `for` loop whose words only reach a GitHub search, for the #7266
//! secret-file read guard (#9001 case 3).
//!
//! Why: `for k in … ".env.*" …; do echo "== $k"; gh issue list --search "$k";
//! done` was refused for naming `.env.*`, though the loop opens no file: its
//! words reach only `echo` and a GitHub query. The word list is refused today
//! because the loop body decides what the words become, and the guard cannot
//! follow the variable in general.
//! What: [`only_feeds_a_search`] follows it in one strict shape. The whole
//! command must be that one loop: its header, then segments that are each
//! `echo`, `gh issue|pr list` or `done`. Every `$` in the command must be the
//! loop variable, used as an `echo` argument or as the whole `--search` value.
//! FAIL-CLOSED: anything else keeps the deny — a second loop or any other
//! program, a pipe, a redirect, `&`, a paren or brace, a here-document, a
//! substitution, `${…}`, an unbalanced quote, or a `$` that is not the loop
//! variable. Neither `echo` nor a gh search opens a path, and a glob in the
//! word list expands to names, never to a file's bytes.
//! Test: `a_for_loop_feeding_only_a_gh_search_names_no_file_9001`,
//! `a_for_loop_whose_words_reach_anything_else_still_denies_9001`.
use crate::commands::pm_guard_bash::{split_shell_segments, tokenize};
use crate::commands::pm_guard_secret_positions::{LIST_INTRODUCERS, lists_gh_issues};
use crate::commands::pm_guard_secret_read::NESTED_COMMAND_MARKERS;
/// Whether `header`, a `for` header segment of `command`, binds words that
/// reach only `echo` and a `gh issue|pr list --search` value (#9001).
///
/// What: see the module doc.
/// Test: `a_for_loop_feeding_only_a_gh_search_names_no_file_9001`,
/// `a_for_loop_whose_words_reach_anything_else_still_denies_9001`.
pub(crate) fn only_feeds_a_search(command: &str, header: &str) -> bool {
let Some(var) = loop_variable(header) else {
return false;
};
if command.contains("<<")
|| NESTED_COMMAND_MARKERS.iter().any(|m| command.contains(m))
|| !plain_separators_only(command)
{
return false;
}
let (mut headers, mut uses) = (0, 0);
for segment in split_shell_segments(command) {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let Ok(argv) = tokenize(segment) else {
return false;
};
let at = argv
.iter()
.position(|t| !LIST_INTRODUCERS.contains(&t.as_str()))
.unwrap_or(argv.len());
let words = &argv[at..];
let found = match words.first().map(String::as_str) {
Some("for") if segment == header.trim() => {
headers += 1;
(!words.iter().any(|w| w.contains('$'))).then_some(0)
}
Some("done") if words.len() == 1 => Some(0),
Some("echo") => echo_uses(&words[1..], &var),
Some("gh") if lists_gh_issues(words) => search_uses(words, &var),
_ => None,
};
let Some(found) = found else {
return false;
};
uses += found;
}
headers == 1 && uses == command.matches('$').count()
}
/// The variable a `for <var> in …` header binds, when it is an identifier.
fn loop_variable(header: &str) -> Option<String> {
let argv = tokenize(header.trim()).ok()?;
let at = argv
.iter()
.position(|t| !LIST_INTRODUCERS.contains(&t.as_str()))?;
let var = argv.get(at + 1)?;
let ident = var.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
&& var.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
(argv[at] == "for" && argv.get(at + 2)? == "in" && ident).then(|| var.clone())
}
/// The `$var` uses in `echo` arguments; `None` when any `$` is another name.
fn echo_uses(args: &[String], var: &str) -> Option<usize> {
let mut uses = 0;
for arg in args {
for (at, _) in arg.match_indices('$') {
let tail = arg[at + 1..].strip_prefix(var)?;
if tail
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
{
return None;
}
uses += 1;
}
}
Some(uses)
}
/// The `$var` uses of a gh call, each the whole `--search` value; `None` when
/// a `$` stands anywhere else.
fn search_uses(argv: &[String], var: &str) -> Option<usize> {
let whole = format!("${var}");
let mut uses = 0;
for (at, token) in argv.iter().enumerate() {
if !token.contains('$') {
continue;
}
let after_flag = at > 0 && argv[at - 1] == "--search" && *token == whole;
if !(after_flag || *token == format!("--search={whole}")) {
return None;
}
uses += 1;
}
Some(uses)
}
/// Whether `command` has balanced quotes and, outside them, no `<`, `>`,
/// `|`, `&`, paren or brace — so `;` and newlines are its only operators.
fn plain_separators_only(command: &str) -> bool {
let (mut single, mut double, mut escaped) = (false, false, false);
for c in command.chars() {
if escaped {
escaped = false;
} else if single {
single = c != '\'';
} else if c == '\\' {
escaped = true;
} else if double {
double = c != '"';
} else if "<>|&(){}".contains(c) {
return false;
} else {
single = c == '\'';
double = c == '"';
}
}
!(single || double || escaped)
}