1use crate::run::RunState;
3use crate::scrub::{Identity, scrub};
4
5pub const NEUTRAL_TITLE: &str = "chore: update repository";
7pub const NEUTRAL_BODY: &str = "Generated GitHub text was withheld by the magi posting gate. Review the branch diff and the local run report for details.";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Violation {
13 TitleLanguage,
15 BodyLanguage,
17 SensitiveData,
19}
20
21pub fn check(title: &str, body: &str) -> Vec<Violation> {
23 let mut out = Vec::new();
24 if non_english(title) {
25 out.push(Violation::TitleLanguage);
26 }
27 if non_english(&prose(body)) {
28 out.push(Violation::BodyLanguage);
29 }
30 let id = Identity::default();
31 if scrub(title, &id) != title || scrub(body, &id) != body {
32 out.push(Violation::SensitiveData);
33 }
34 out
35}
36
37const FOREIGN_WORDS: &[&str] = &[
40 "este",
41 "esta",
42 "para",
43 "los",
44 "las",
45 "del",
46 "una",
47 "que",
48 "por",
49 "errores",
50 "corregir",
51 "agrega",
52 "cambio",
53 "solicitudes",
54 "fallidas",
55 "reintentos",
56 "les",
57 "des",
58 "pour",
59 "avec",
60 "dans",
61 "est",
62 "une",
63 "pas",
64 "und",
65 "der",
66 "das",
67 "nicht",
68 "mit",
69 "ein",
70 "eine",
71 "für",
72 "wird",
73 "não",
74 "uma",
75 "della",
76 "che",
77 "con",
78 "fehler",
79 "corrigir",
80 "erreurs",
81];
82
83fn foreign_words(text: &str) -> bool {
84 let words: Vec<String> = text
85 .split(|c: char| !c.is_alphabetic())
86 .filter(|w| !w.is_empty())
87 .map(str::to_lowercase)
88 .collect();
89 let hits = words
90 .iter()
91 .filter(|w| FOREIGN_WORDS.contains(&w.as_str()))
92 .count();
93 hits >= 2 && hits * 4 >= words.len()
94}
95
96fn non_english(text: &str) -> bool {
97 if foreign_words(text) {
98 return true;
99 }
100 let letters = text.chars().filter(|c| c.is_alphabetic()).count();
101 let foreign = text
102 .chars()
103 .filter(|c| c.is_alphabetic() && !c.is_ascii())
104 .count();
105 (foreign >= 4 && foreign * 10 >= letters.max(1))
107 || text.lines().any(|line| {
108 let letters = line.chars().filter(|c| c.is_alphabetic()).count();
109 let foreign = line
110 .chars()
111 .filter(|c| c.is_alphabetic() && !c.is_ascii())
112 .count();
113 foreign >= 4 && foreign * 2 >= letters.max(1)
114 })
115}
116
117fn closing_run(text: &str, n: usize) -> Option<usize> {
120 let mut at = 0;
121 while let Some(i) = text[at..].find('`') {
122 let start = at + i;
123 let len = text[start..].chars().take_while(|c| *c == '`').count();
124 if len == n {
125 return Some(start + len);
126 }
127 at = start + len;
128 }
129 None
130}
131
132fn prose(body: &str) -> String {
133 let mut out = String::new();
134 let mut details = 0usize;
135 let mut quote = false;
136 let mut fence: Option<(char, usize)> = None;
137 for line in body.lines() {
138 let trimmed = line.trim_start();
139 if let Some((marker, width)) = fence {
140 if trimmed.chars().take_while(|c| *c == marker).count() >= width {
141 fence = None;
142 }
143 continue;
144 }
145 let marker = trimmed.chars().next().unwrap_or(' ');
146 let width = trimmed.chars().take_while(|c| *c == marker).count();
147 if matches!(marker, '`' | '~') && width >= 3 {
148 fence = Some((marker, width));
149 continue;
150 }
151 if trimmed.starts_with('>') || line.starts_with(" ") || line.starts_with('\t') {
152 continue;
153 }
154 let mut rest = line;
155 while !rest.is_empty() {
156 if rest.starts_with('<')
157 && let Some(end) = rest.find('>')
158 {
159 let tag = rest[..=end].to_ascii_lowercase();
160 if tag.starts_with("<details") {
161 details += 1;
162 } else if tag == "</details>" {
163 details = details.saturating_sub(1);
164 } else if tag.starts_with("<blockquote") {
165 quote = true;
166 } else if tag == "</blockquote>" {
167 quote = false;
168 }
169 rest = &rest[end + 1..];
170 continue;
171 }
172 if rest.starts_with('`') {
173 let n = rest.chars().take_while(|c| *c == '`').count();
174 rest = match closing_run(&rest[n..], n) {
175 Some(end) => &rest[n + end..],
176 None => &rest[n..],
177 };
178 continue;
179 }
180 let c = rest.chars().next().expect("nonempty");
181 if details == 0 && !quote {
182 out.push(c);
183 }
184 rest = &rest[c.len_utf8()..];
185 }
186 out.push('\n');
187 }
188 out
189}
190
191pub fn prepare(state: &mut RunState, title: &str, body: &str) -> (String, String) {
194 let id = Identity::current();
195 let clean_title = scrub(title, &id);
196 let clean_body = scrub(body, &id);
197 let violations = check(title, body);
198 if clean_title != title || clean_body != body {
199 state.event("github-text", "sensitive data removed before posting");
200 }
201 let language = state.config.graph.github_text_guard;
202 let title = if language && violations.contains(&Violation::TitleLanguage) {
203 state.event("github-text", "title replaced with neutral English text");
204 NEUTRAL_TITLE.to_owned()
205 } else {
206 clean_title
207 };
208 let body = if language && violations.contains(&Violation::BodyLanguage) {
209 state.event("github-text", "body replaced with neutral English text");
210 NEUTRAL_BODY.to_owned()
211 } else {
212 clean_body
213 };
214 (title, body)
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn github_text_language_and_exemptions() {
223 assert_eq!(
224 check("fix: retries", "日本語で変更の説明を書きます。"),
225 vec![Violation::BodyLanguage]
226 );
227 assert!(check("fix: retries", "Add retries for failed requests.\n<details>\n<summary>Original task</summary>\n日本語の元の依頼です。\n</details>").is_empty());
228 for body in [
229 "Fix `cache\n\n日本語の説明を書きます。",
230 "Fix `cache 日本語の説明を書きます。",
231 ] {
232 assert_eq!(
233 check("fix: retries", body),
234 vec![Violation::BodyLanguage],
235 "{body}"
236 );
237 }
238 for body in [
239 "Add retries. `日本語の識別子`",
240 "Add retries.\n```text\n日本語のコードです\n```",
241 "Add retries.\n> 日本語の引用です",
242 "Add retries.\n<blockquote>日本語の引用です</blockquote>",
243 "Add retries. ``日本語 ` の識別子``",
244 "Update café names.",
245 "Change src/graph.rs and tests/common/mod.rs.",
246 ] {
247 assert!(check("fix: retries", body).is_empty(), "{body}");
248 }
249 }
250
251 #[test]
252 fn github_text_sensitive_data_in_all_sections() {
253 for secret in [
254 "/Users/example/repo",
255 "/home/example/repo",
256 "C:\\Users\\Example\\repo",
257 "/private/tmp/work",
258 "dev@example.test",
259 "ghp_abcdefghijklmnopqrstuv",
260 "github_pat_abcdefghijklmnopqrstuv",
261 "sk-abcdefghijklmnopqrstuv",
262 "AKIAABCDEFGHIJKLMNOP",
263 "password=example",
264 "password=\"example\"",
265 "token aBcdEfgHijkLmn0123456789",
266 "buildbox.local",
267 "token=abcdefghijklmnop012345",
268 "Authorization: Bearer abcdefghijklmnop",
269 "hostname=buildbox",
270 "username=example",
271 "10.2.3.4",
272 "fe80::1",
273 ] {
274 assert!(
275 check(secret, "").contains(&Violation::SensitiveData),
276 "{secret}"
277 );
278 assert!(
279 check(
280 "fix: retries",
281 &format!("<details>\n`{secret}`\n</details>")
282 )
283 .contains(&Violation::SensitiveData),
284 "{secret}"
285 );
286 }
287 for clean in [
288 "https://github.com/example/repo",
289 "src/graph.rs",
290 "docs/home/example",
291 "/api/v1/runs",
292 "v1.2.3",
293 "std::io::Error",
294 "Use the token from the environment.",
295 ] {
296 assert!(check("fix: retries", clean).is_empty(), "{clean}");
297 }
298 }
299
300 #[test]
301 fn github_text_config_only_disables_language_and_records_interventions() {
302 let mut state = RunState::new(
303 ".".into(),
304 "main".into(),
305 "abc".into(),
306 "task".into(),
307 crate::config::Config::default(),
308 );
309 let (_, body) = prepare(
310 &mut state,
311 "fix: retries",
312 "日本語の説明を書きます。 token=secret",
313 );
314 assert_eq!(body, NEUTRAL_BODY);
315 assert!(!state.events.is_empty());
316 state.config.graph.github_text_guard = false;
317 let (_, body) = prepare(
318 &mut state,
319 "fix: retries",
320 "日本語の説明を書きます。 token=secret",
321 );
322 assert!(body.contains("日本語"));
323 assert!(!body.contains("secret"));
324 assert!(
325 check("fix: retries", &body)
326 .iter()
327 .all(|v| *v != Violation::SensitiveData)
328 );
329 }
330
331 #[test]
332 fn github_text_fixed_fallback_passes() {
333 assert!(check(NEUTRAL_TITLE, NEUTRAL_BODY).is_empty());
334 }
335}
336
337#[cfg(test)]
338mod review_round_tests {
339 use super::*;
340
341 #[test]
342 fn latin_script_non_english_is_flagged() {
343 assert!(check("Corregir errores", "fix: retry").contains(&Violation::TitleLanguage));
344 assert!(
345 check(
346 "fix: retries",
347 "Este cambio agrega reintentos para solicitudes fallidas."
348 )
349 .contains(&Violation::BodyLanguage)
350 );
351 assert!(
352 check(
353 "fix: retry failed requests",
354 "Adds retries for failed requests."
355 )
356 .is_empty()
357 );
358 }
359
360 #[test]
361 fn quoted_json_credentials_are_sensitive() {
362 let body = "Example: {\"password\": \"hunter2\"}";
363 assert!(check("t", body).contains(&Violation::SensitiveData));
364 assert!(!crate::scrub::scrub(body, &Identity::default()).contains("hunter2"));
365 }
366}
367
368#[cfg(test)]
369mod quoted_value_tests {
370 use crate::scrub::{Identity, scrub};
371
372 #[test]
373 fn quoted_values_are_redacted_whole() {
374 for v in ["correct horse battery staple", ",hunter2"] {
375 let out = scrub(
376 &format!("{{\"password\": \"{v}\"}} ok"),
377 &Identity::default(),
378 );
379 assert!(!out.contains("horse") && !out.contains("hunter2"), "{out}");
380 assert!(out.ends_with("ok"), "{out}");
381 }
382 }
383}