use crate::rules::{Confirmed, Context, Evidence, Examine, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const RULE: Rule = Rule {
id: "gui-open-unbounded",
default_stance: Stance::Advise,
max_stance: Stance::Deny,
evidence: Evidence {
per_1000: 0.0,
measured: "2026-10-14",
trend: Trend::Rare,
},
examine: Examine::Legacy(examine),
confirm: Some(confirm),
};
fn examine(parsed: &Parsed) -> Option<Finding> {
let cmd = parsed
.judgeable()
.find(|c| c.program() == Some("open") && !c.wrapped_by("timeout") && launches_an_app(c))?;
Some(Finding {
reason: "`open` of an app can block with nothing to end it: on 2026-10-14 `open -a \
Simulator` was measured to hang twice (63 and 10 minutes), `-g` and `-j` did \
not help, and in a background call the chain after it never ran, the output \
stayed empty and no completion notice came."
.to_string(),
remedy: "Run `timeout 15 open -a <App>` as a Bash call of its own, or skip the GUI: \
`xcrun simctl boot`, `simctl io <udid> screenshot` and `simctl openurl` work \
headless."
.to_string(),
span: cmd.at..cmd.end,
})
}
fn launches_an_app(cmd: &Simple) -> bool {
let mut args = cmd.args().iter();
while let Some(w) = args.next() {
if w.quoted {
if is_app_path(&w.text) {
return true;
}
continue;
}
let t = w.text.as_str();
match t {
"--args" => return false,
"--reveal" => return false,
_ if t.starts_with("--") => {}
_ if t.starts_with('-') && t.len() > 1 => {
let cluster = &t[1..];
if cluster.contains('R') {
return false;
}
match cluster.chars().last() {
Some('a') | Some('b') => return true,
Some('f') | Some('t') | Some('e') | Some('F') | Some('s') => {
args.next();
}
_ => {}
}
}
_ if is_app_path(t) => return true,
_ => {}
}
}
false
}
fn is_app_path(word: &str) -> bool {
word.trim_end_matches('/').ends_with(".app")
}
fn confirm(ctx: &Context, _f: &Finding) -> Confirmed {
if !ctx.background {
return Confirmed::No("in the foreground the tool's own timeout bounds the launch");
}
Confirmed::Yes
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn fires(command: &str) -> bool {
examine(&lex(command)).is_some()
}
#[test]
fn an_app_launch_is_examined() {
assert!(fires("open -a Simulator"));
assert!(fires("open -g -a Simulator"));
assert!(fires("open -ja Simulator"));
assert!(fires("open -a \"Google Chrome\" https://example.com"));
assert!(fires("open /Applications/Xcode.app"));
assert!(fires("open -b com.apple.iphonesimulator"));
assert!(fires("cd x && open -a Simulator; yarn ios"));
}
#[test]
fn a_document_a_url_or_a_reveal_is_not() {
assert!(!fires("open README.md"));
assert!(!fires("open https://example.com"));
assert!(!fires("open ."));
assert!(!fires("open -R /Applications/Xcode.app"));
assert!(!fires("echo open -a Simulator"));
assert!(!fires("git commit -m \"open -a Simulator hangs\""));
}
#[test]
fn timeout_bounds_its_own_clause_only() {
assert!(!fires("timeout 15 open -a Simulator"));
assert!(!fires("timeout -k 5 15 open -a Simulator && echo ok"));
assert!(fires("timeout 15 open -a Simulator; open -a Safari"));
assert!(fires("timeout 15 open -a Simulator && open -g -a Safari"));
}
#[test]
fn the_apps_own_arguments_are_not_ours() {
assert!(!fires("open ./notes.txt --args -a"));
}
}