use super::model::ResourceLimits;
use super::BackendCapabilities;
use anyhow::{bail, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedResourceBudget {
pub timeout_ms: u64,
pub max_output_bytes: usize,
pub max_processes: Option<u32>,
pub max_memory_bytes: Option<u64>,
}
impl ResolvedResourceBudget {
pub fn resolve(policy: &ResourceLimits, request_timeout_ms: u64) -> Result<Self> {
if request_timeout_ms == 0 {
bail!("command timeout must be greater than zero");
}
if policy.timeout_ms == 0 {
bail!("policy timeout_ms must be greater than zero");
}
if policy.max_output_bytes == 0 {
bail!("policy max_output_bytes must be greater than zero");
}
if let Some(0) = policy.max_processes {
bail!("policy max_processes must be greater than zero when set");
}
if let Some(0) = policy.max_memory_bytes {
bail!("policy max_memory_bytes must be greater than zero when set");
}
if policy.max_call_depth.is_some() {
bail!(
"max_call_depth is not enforceable by the OS process boundary; fail closed \
instead of silently ignoring it"
);
}
Ok(Self {
timeout_ms: request_timeout_ms.min(policy.timeout_ms),
max_output_bytes: policy.max_output_bytes,
max_processes: policy.max_processes,
max_memory_bytes: policy.max_memory_bytes,
})
}
pub fn validate_for_backend(self, capabilities: BackendCapabilities) -> Result<()> {
if !capabilities.resource_timeout {
bail!("backend cannot enforce command timeouts; fail closed");
}
if !capabilities.resource_output_limit {
bail!("backend cannot enforce output capture limits; fail closed");
}
if self.max_processes.is_some() && !capabilities.resource_process_limit {
bail!("process limit requested but backend cannot enforce it; fail closed");
}
if self.max_memory_bytes.is_some() && !capabilities.resource_memory_limit {
bail!("memory limit requested but backend cannot enforce it; fail closed");
}
Ok(())
}
}
#[cfg(unix)]
pub(crate) fn apply_unix_rlimits(budget: &ResolvedResourceBudget) -> Result<()> {
if budget.max_processes.is_some() {
bail!(
"process limit requested but Unix backends cannot enforce a process-tree \
quota without cgroup (or equivalent); fail closed"
);
}
if let Some(max_memory_bytes) = budget.max_memory_bytes {
#[cfg(target_os = "linux")]
{
set_rlimit(libc::RLIMIT_AS, max_memory_bytes)?;
}
#[cfg(not(target_os = "linux"))]
{
let _ = max_memory_bytes;
bail!(
"memory limit requested but this Unix platform cannot enforce address-space \
quotas via rlimit; fail closed"
);
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn set_rlimit(resource: libc::__rlimit_resource_t, soft_and_hard: u64) -> Result<()> {
let limit = libc::rlimit {
rlim_cur: soft_and_hard as libc::rlim_t,
rlim_max: soft_and_hard as libc::rlim_t,
};
let rc = unsafe { libc::setrlimit(resource, &limit) };
if rc != 0 {
bail!(
"failed to set rlimit resource={resource:?} value={soft_and_hard}: {}",
std::io::Error::last_os_error()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::model::ResourceLimits;
use crate::MAX_OUTPUT_SIZE;
#[test]
fn resolve_caps_request_timeout_by_policy() {
let policy = ResourceLimits {
timeout_ms: 1_000,
max_output_bytes: MAX_OUTPUT_SIZE,
..ResourceLimits::default()
};
let budget = ResolvedResourceBudget::resolve(&policy, 5_000).unwrap();
assert_eq!(budget.timeout_ms, 1_000);
assert_eq!(budget.max_output_bytes, MAX_OUTPUT_SIZE);
}
#[test]
fn resolve_uses_policy_output_ceiling() {
let policy = ResourceLimits {
max_output_bytes: 2_048,
..ResourceLimits::default()
};
let budget = ResolvedResourceBudget::resolve(&policy, 30_000).unwrap();
assert_eq!(budget.max_output_bytes, 2_048);
}
#[test]
fn resolve_rejects_call_depth_as_unenforcible() {
let policy = ResourceLimits {
max_call_depth: Some(32),
..ResourceLimits::default()
};
let error = ResolvedResourceBudget::resolve(&policy, 1_000)
.unwrap_err()
.to_string();
assert!(error.contains("max_call_depth"), "{error}");
}
#[test]
fn resolve_rejects_zero_process_or_memory_limits() {
let policy = ResourceLimits {
max_processes: Some(0),
..ResourceLimits::default()
};
assert!(ResolvedResourceBudget::resolve(&policy, 1_000).is_err());
let policy = ResourceLimits {
max_memory_bytes: Some(0),
..ResourceLimits::default()
};
assert!(ResolvedResourceBudget::resolve(&policy, 1_000).is_err());
}
#[test]
fn gate2_capabilities_can_enforce_optional_process_and_memory() {
let caps = BackendCapabilities::native_gate2();
assert_eq!(caps.resource_process_limit, cfg!(windows));
assert_eq!(
caps.resource_memory_limit,
cfg!(any(windows, target_os = "linux"))
);
let memory_only = ResourceLimits {
max_memory_bytes: Some(64 * 1024 * 1024),
..ResourceLimits::default()
};
let budget = ResolvedResourceBudget::resolve(&memory_only, 1_000).unwrap();
if caps.resource_memory_limit {
budget.validate_for_backend(caps).unwrap();
} else {
let error = budget.validate_for_backend(caps).unwrap_err().to_string();
assert!(error.contains("memory limit"), "{error}");
}
let with_processes = ResourceLimits {
max_processes: Some(32),
..ResourceLimits::default()
};
let budget = ResolvedResourceBudget::resolve(&with_processes, 1_000).unwrap();
if caps.resource_process_limit {
budget.validate_for_backend(caps).unwrap();
} else {
let error = budget.validate_for_backend(caps).unwrap_err().to_string();
assert!(error.contains("process limit"), "{error}");
}
}
}