use crate::core::config;
const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; const HEAVY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
pub(in crate::shell) fn exec_limits(command: &str) -> (usize, std::time::Duration) {
let max_bytes = if is_heavy_command(command) {
HEAVY_MAX_BYTES
} else {
DEFAULT_MAX_BYTES
};
(max_bytes, shell_timeout(command))
}
#[must_use]
pub(crate) fn shell_timeout(command: &str) -> std::time::Duration {
shell_timeout_with_override(command, None)
}
const MAX_CALL_TIMEOUT_MS: u64 = 3_600_000;
#[must_use]
pub(crate) fn shell_timeout_with_override(
command: &str,
override_ms: Option<u64>,
) -> std::time::Duration {
if let Some(ms) = env_u64("LEAN_CTX_SHELL_TIMEOUT_MS") {
return std::time::Duration::from_millis(ms);
}
if let Some(ms) = override_ms.filter(|n| *n > 0) {
return std::time::Duration::from_millis(ms.min(MAX_CALL_TIMEOUT_MS));
}
if is_heavy_command(command) {
if let Some(secs) = env_u64("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS")
.or_else(|| config::Config::load().shell_heavy_timeout_secs)
{
return std::time::Duration::from_secs(secs);
}
HEAVY_TIMEOUT
} else {
if let Some(secs) = env_u64("LEAN_CTX_SHELL_TIMEOUT_SECS")
.or_else(|| config::Config::load().shell_timeout_secs)
{
return std::time::Duration::from_secs(secs);
}
DEFAULT_TIMEOUT
}
}
fn env_u64(var: &str) -> Option<u64> {
std::env::var(var)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|n| *n > 0)
}
fn is_loop_command(lower: &str) -> bool {
let trimmed = lower.trim_start();
trimmed.starts_with("while ")
|| trimmed.starts_with("until ")
|| (trimmed.starts_with("for ") && trimmed.contains(" in "))
}
fn strip_env_prefix(cmd: &str) -> &str {
let mut rest = cmd;
loop {
let trimmed = rest.trim_start();
if let Some(eq_pos) = trimmed.find('=') {
let before_eq = &trimmed[..eq_pos];
if !before_eq.is_empty()
&& before_eq
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
&& let Some(space_pos) = trimmed[eq_pos..].find(' ')
{
rest = &trimmed[eq_pos + space_pos..];
continue;
}
}
return trimmed;
}
}
fn is_heavy_command(command: &str) -> bool {
let cmd = command.trim();
let lower = cmd.to_lowercase();
if is_loop_command(&lower) {
return true;
}
static HEAVY_PREFIXES: &[&str] = &[
"cargo build",
"cargo test",
"cargo nextest",
"cargo clippy",
"cargo check",
"cargo install",
"cargo bench",
"npm run build",
"npm install",
"npm ci",
"pnpm install",
"pnpm build",
"yarn install",
"yarn build",
"bun install",
"make",
"cmake",
"bazel build",
"bazel test",
"gradle build",
"gradle test",
"mvn package",
"mvn install",
"mvn test",
"go build",
"go test",
"dotnet build",
"dotnet test",
"swift build",
"swift test",
"flutter build",
"docker build",
"docker compose build",
"pip install",
"poetry install",
"uv sync",
"bundle install",
"mix compile",
"git commit",
"git push",
"python3 ",
"python ",
"pytest",
"bash scripts/",
"sh scripts/",
"./scripts/",
"timeout ",
"mise ",
"just ",
];
let cfg_prefixes = config::Config::load().shell_heavy_prefixes;
let matches_heavy = |s: &str| {
HEAVY_PREFIXES.iter().any(|p| s.starts_with(p))
|| cfg_prefixes.iter().any(|p| s.starts_with(p.as_str()))
};
if matches_heavy(&lower) {
return true;
}
let stripped = strip_env_prefix(&lower);
if stripped != lower && matches_heavy(stripped) {
return true;
}
let final_cmd = lower
.rsplit_once("&&")
.or_else(|| lower.rsplit_once(';'))
.map_or("", |(_, rhs)| rhs.trim());
if final_cmd.is_empty() {
return false;
}
let final_stripped = strip_env_prefix(final_cmd);
matches_heavy(final_cmd) || matches_heavy(final_stripped)
}
#[cfg(test)]
mod tests {
#[test]
fn heavy_commands_get_higher_byte_limits() {
for cmd in [
"cargo build --release",
"cargo test --lib",
"cargo nextest run",
"npm run build",
"docker build -t myapp .",
"git commit --amend --no-edit",
"git push -u origin HEAD",
"cd /some/path && cargo test --lib",
"cd /foo/bar && cargo build --release",
"cd /workspace; npm ci",
] {
let (bytes, _) = super::exec_limits(cmd);
assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
}
}
#[test]
fn normal_commands_get_default_byte_limits() {
for cmd in ["echo hello", "git status", "git log --oneline -5"] {
let (bytes, _) = super::exec_limits(cmd);
assert_eq!(
bytes,
super::DEFAULT_MAX_BYTES,
"default byte limit for {cmd}"
);
}
}
#[test]
fn env_prefixed_commands_get_correct_heavy_classification() {
for cmd in [
"LEAN_CTX_DISABLED=1 cargo test --lib",
"FOO=bar RUST_LOG=debug cargo test --lib",
"LEAN_CTX_DISABLED=1 cargo build --release",
"NODE_ENV=production npm run build",
"cd /path && LEAN_CTX_DISABLED=1 cargo test --lib",
"cd /path; FOO=bar npm run build",
"cargo test --lib",
"npm run build",
"go test ./...",
"cargo clippy --all-features -- -D warnings",
] {
assert!(
super::is_heavy_command(cmd),
"expected heavy command: {cmd}"
);
}
}
#[test]
fn ordinary_commands_remain_non_heavy_with_or_without_env_prefixes() {
for cmd in [
"FOO=bar ls -la",
"LEAN_CTX_DISABLED=1 echo hello",
"ls -la",
"echo hello",
"cat file.txt",
] {
assert!(
!super::is_heavy_command(cmd),
"expected non-heavy command: {cmd}"
);
}
}
#[test]
fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
let _lock = crate::core::data_dir::test_env_lock();
let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
for v in [
"LEAN_CTX_SHELL_TIMEOUT_MS",
"LEAN_CTX_SHELL_TIMEOUT_SECS",
"LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
] {
crate::test_env::remove_var(v);
}
assert_eq!(
super::shell_timeout("cargo install --path ."),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("cargo nextest run"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("git commit -m 'wip'"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("git push origin main"),
super::HEAVY_TIMEOUT
);
assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
assert_eq!(
super::shell_timeout("cd /some/project && cargo test --lib"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("cd /workspace && cargo build --release"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("cd /app; npm ci"),
super::HEAVY_TIMEOUT
);
crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
assert_eq!(
super::shell_timeout("cargo build"),
std::time::Duration::from_secs(90)
);
crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
assert_eq!(
super::shell_timeout("git status"),
std::time::Duration::from_secs(30)
);
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
assert_eq!(
super::shell_timeout("cargo build"),
std::time::Duration::from_secs(5)
);
assert_eq!(
super::shell_timeout("git status"),
std::time::Duration::from_secs(5)
);
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
for (var, saved) in [
("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
] {
if let Some(v) = saved {
crate::test_env::set_var(var, v);
}
}
}
#[test]
fn task_runners_get_heavy_ceiling() {
let _lock = crate::core::data_dir::test_env_lock();
let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
assert_eq!(super::shell_timeout("mise gate"), super::HEAVY_TIMEOUT);
assert_eq!(super::shell_timeout("mise run gate"), super::HEAVY_TIMEOUT);
assert_eq!(super::shell_timeout("just build"), super::HEAVY_TIMEOUT);
if let Some(v) = saved_ms {
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
}
if let Some(v) = saved_heavy {
crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
}
}
#[test]
fn scripts_and_timeout_get_heavy_ceiling() {
let _lock = crate::core::data_dir::test_env_lock();
let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
assert_eq!(
super::shell_timeout("python3 scripts/audit.py"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("python scripts/gate.py --root ."),
super::HEAVY_TIMEOUT
);
assert_eq!(super::shell_timeout("pytest tests/"), super::HEAVY_TIMEOUT);
assert_eq!(
super::shell_timeout("bash scripts/loc-gate.sh"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("sh scripts/run.sh"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("./scripts/deploy.sh"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("timeout 300 python3 audit.py"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("cd /repo && python3 gate.py"),
super::HEAVY_TIMEOUT
);
assert_eq!(
super::shell_timeout("cd /repo && timeout 600 make"),
super::HEAVY_TIMEOUT
);
if let Some(v) = saved_ms {
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
}
if let Some(v) = saved_heavy {
crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
}
}
#[test]
fn per_call_timeout_override_resolves_and_clamps() {
let _lock = crate::core::data_dir::test_env_lock();
let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
assert_eq!(
super::shell_timeout_with_override("git status", Some(300_000)),
std::time::Duration::from_mins(5)
);
assert_eq!(
super::shell_timeout_with_override("cargo build", Some(30_000)),
std::time::Duration::from_secs(30)
);
assert_eq!(
super::shell_timeout_with_override("git status", Some(999_000_000)),
std::time::Duration::from_millis(super::MAX_CALL_TIMEOUT_MS)
);
assert_eq!(
super::shell_timeout_with_override("git status", Some(0)),
super::DEFAULT_TIMEOUT
);
assert_eq!(
super::shell_timeout_with_override("git status", None),
super::DEFAULT_TIMEOUT
);
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
assert_eq!(
super::shell_timeout_with_override("git status", Some(300_000)),
std::time::Duration::from_secs(5)
);
crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
if let Some(v) = saved_ms {
crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
}
}
}