#[cfg(target_os = "macos")]
use crate::capability::merge_port_ranges;
use crate::capability::{
AccessMode as InternalAccessMode, CapabilitySet, IpcMode as InternalIpcMode,
NetworkMode as InternalNetworkMode, ProcessInfoMode as InternalProcessInfoMode,
SignalMode as InternalSignalMode,
};
use crate::manifest::{
AccessMode, CapabilityManifest, FsEntryType, IpcMode, NetworkMode, ProcessInfoMode, Resources,
SignalMode,
};
use crate::resource::ResourceLimits;
use crate::{NonoError, Result};
impl TryFrom<&CapabilityManifest> for CapabilitySet {
type Error = NonoError;
fn try_from(manifest: &CapabilityManifest) -> Result<Self> {
manifest.validate()?;
let mut caps = CapabilitySet::new();
if let Some(ref fs) = manifest.filesystem {
for grant in &fs.grants {
let mode = convert_access_mode(grant.access);
let path = grant.path.as_str();
caps = match grant.type_ {
FsEntryType::File => caps.allow_file(path, mode)?,
FsEntryType::Directory => caps.allow_path(path, mode)?,
};
}
}
if let Some(ref net) = manifest.network {
caps = match net.mode {
NetworkMode::Blocked => caps.block_network(),
NetworkMode::Proxy => caps.set_network_mode(InternalNetworkMode::ProxyOnly {
port: 0,
bind_ports: vec![],
}),
NetworkMode::Unrestricted => caps.set_network_mode(InternalNetworkMode::AllowAll),
};
if let Some(ref ports) = net.ports {
for port in &ports.connect {
let p = u16::try_from(port.get()).map_err(|_| {
NonoError::ConfigParse(format!("port {} exceeds u16 range", port))
})?;
caps = caps.allow_tcp_connect(p);
}
for port in &ports.bind {
let p = u16::try_from(port.get()).map_err(|_| {
NonoError::ConfigParse(format!("port {} exceeds u16 range", port))
})?;
caps = caps.allow_tcp_bind(p);
}
for port in &ports.localhost {
let p = u16::try_from(port.get()).map_err(|_| {
NonoError::ConfigParse(format!("port {} exceeds u16 range", port))
})?;
caps = caps.allow_localhost_port(p);
}
let mut raw_ranges: Vec<(u16, u16)> = Vec::new();
for &[start, end] in &ports.localhost_range {
let start_u = start.get();
let end_u = end.get();
let (start, end) = match (u16::try_from(start_u), u16::try_from(end_u)) {
(Ok(s), Ok(e)) => (s, e),
_ => {
return Err(NonoError::ConfigParse(format!(
"localhost_range entry [{start_u}, {end_u}] is invalid: ports must be in 1–65535"
)));
}
};
if start > end {
return Err(NonoError::ConfigParse(format!(
"localhost_range entry [{start}, {end}] is invalid: start must be <= end"
)));
}
raw_ranges.push((start, end));
}
#[cfg(target_os = "macos")]
{
let merged = merge_port_ranges(&raw_ranges);
let total: u32 = merged
.iter()
.map(|&(s, e)| (e as u32).saturating_sub(s as u32).saturating_add(1))
.fold(0u32, |acc, n| acc.saturating_add(n));
if total > crate::capability::MACOS_PORT_RANGE_LIMIT {
return Err(NonoError::ConfigParse(format!(
"localhost_range entries expand to {} unique ports, which exceeds the macOS limit of {} \
(sandbox_init crashes above ~17,770 rules); use smaller or fewer ranges",
total,
crate::capability::MACOS_PORT_RANGE_LIMIT
)));
}
}
for (start, end) in raw_ranges {
caps = caps.allow_localhost_port_range(start, end)?;
}
}
}
if let Some(ref proc) = manifest.process {
caps = caps.set_signal_mode(convert_signal_mode(proc.signal_mode));
caps = caps.set_process_info_mode(convert_process_info_mode(proc.process_info_mode));
caps = caps.set_ipc_mode(convert_ipc_mode(proc.ipc_mode));
for cmd in &proc.allowed_commands {
caps = caps.allow_command(cmd.clone());
}
for cmd in &proc.blocked_commands {
caps = caps.block_command(cmd.clone());
}
}
if let Some(ref res) = manifest.resources {
caps = caps.with_resource_limits(convert_resources(res));
}
Ok(caps)
}
}
fn convert_resources(res: &Resources) -> ResourceLimits {
ResourceLimits {
memory_bytes: res.memory_bytes.map(|n| n.get()),
max_processes: res.max_processes.map(|n| n.get()),
}
}
fn convert_access_mode(mode: AccessMode) -> InternalAccessMode {
match mode {
AccessMode::Read => InternalAccessMode::Read,
AccessMode::Write => InternalAccessMode::Write,
AccessMode::Readwrite => InternalAccessMode::ReadWrite,
}
}
fn convert_signal_mode(mode: SignalMode) -> InternalSignalMode {
match mode {
SignalMode::Isolated => InternalSignalMode::Isolated,
SignalMode::AllowSameSandbox => InternalSignalMode::AllowSameSandbox,
SignalMode::AllowAll => InternalSignalMode::AllowAll,
}
}
fn convert_process_info_mode(mode: ProcessInfoMode) -> InternalProcessInfoMode {
match mode {
ProcessInfoMode::Isolated => InternalProcessInfoMode::Isolated,
ProcessInfoMode::AllowSameSandbox => InternalProcessInfoMode::AllowSameSandbox,
ProcessInfoMode::AllowAll => InternalProcessInfoMode::AllowAll,
}
}
fn convert_ipc_mode(mode: IpcMode) -> InternalIpcMode {
match mode {
IpcMode::SharedMemoryOnly => InternalIpcMode::SharedMemoryOnly,
IpcMode::Full => InternalIpcMode::Full,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn manifest_resources_map_into_capability_set() {
let json = r#"{
"version": "0.1.0",
"process": { "exec_strategy": "supervised" },
"resources": { "memory_bytes": 1048576 }
}"#;
let manifest = CapabilityManifest::from_json(json).unwrap();
let caps = CapabilitySet::try_from(&manifest).unwrap();
let limits = caps.resource_limits().expect("limits present");
assert_eq!(limits.memory_bytes, Some(1048576));
}
#[test]
fn manifest_without_resources_has_no_limits() {
let json = r#"{ "version": "0.1.0" }"#;
let manifest = CapabilityManifest::from_json(json).unwrap();
let caps = CapabilitySet::try_from(&manifest).unwrap();
assert!(caps.resource_limits().is_none());
}
#[test]
fn manifest_max_processes_maps_into_capability_set() {
let json = r#"{
"version": "0.1.0",
"process": { "exec_strategy": "supervised" },
"resources": { "max_processes": 64 }
}"#;
let manifest = CapabilityManifest::from_json(json).unwrap();
let caps = CapabilitySet::try_from(&manifest).unwrap();
let limits = caps.resource_limits().expect("limits present");
assert_eq!(limits.max_processes, Some(64));
assert_eq!(limits.memory_bytes, None);
}
#[test]
fn manifest_both_ceilings_map_together() {
let json = r#"{
"version": "0.1.0",
"process": { "exec_strategy": "supervised" },
"resources": { "memory_bytes": 1048576, "max_processes": 32 }
}"#;
let manifest = CapabilityManifest::from_json(json).unwrap();
let caps = CapabilitySet::try_from(&manifest).unwrap();
let limits = caps.resource_limits().expect("limits present");
assert_eq!(limits.memory_bytes, Some(1048576));
assert_eq!(limits.max_processes, Some(32));
}
#[test]
fn try_from_rejects_unsupervised_max_processes() {
let json = r#"{ "version": "0.1.0", "resources": { "max_processes": 8 } }"#;
let manifest = CapabilityManifest::from_json(json).unwrap();
let err = CapabilitySet::try_from(&manifest)
.expect_err("unsupervised max_processes limit must be rejected by TryFrom");
assert!(matches!(err, NonoError::ConfigParse(_)), "got {err:?}");
}
#[test]
fn try_from_runs_validate_and_rejects_unsupervised_memory() {
let json = r#"{ "version": "0.1.0", "resources": { "memory_bytes": 1024 } }"#;
let manifest = CapabilityManifest::from_json(json).unwrap();
let err = CapabilitySet::try_from(&manifest)
.expect_err("unsupervised memory limit must be rejected by TryFrom");
assert!(matches!(err, NonoError::ConfigParse(_)), "got {err:?}");
}
#[test]
fn empty_resources_object_maps_to_no_ceiling() {
let json = r#"{ "version": "0.1.0", "resources": {} }"#;
let manifest = CapabilityManifest::from_json(json).unwrap();
let caps = CapabilitySet::try_from(&manifest).unwrap();
let limits = caps
.resource_limits()
.expect("empty resources must still attach a ResourceLimits");
assert!(
limits.is_empty(),
"empty resources must not produce a ceiling, got {limits:?}"
);
}
}