use crate::cli::PolicyFormat;
use crate::engine::error::Error;
use anyhow::{Context, Result};
use serde::{Deserialize, Deserializer};
use serde_json::{Map, Value};
use std::error::Error as StdError;
use std::ffi::OsStr;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub(crate) struct Settings {
pub(crate) filesystem: SandboxFilesystem,
pub(crate) network: SandboxNetwork,
pub(crate) windows: SandboxWindows,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub(crate) struct SandboxFilesystem {
#[serde(deserialize_with = "deserialize_paths")]
pub(crate) allow_write: Vec<String>,
#[serde(deserialize_with = "deserialize_paths")]
pub(crate) deny_write: Vec<String>,
#[serde(deserialize_with = "deserialize_paths")]
pub(crate) allow_read: Vec<String>,
#[serde(deserialize_with = "deserialize_paths")]
pub(crate) deny_read: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub(crate) struct SandboxNetwork {
pub(crate) allow_network: bool,
pub(crate) allow_local_binding: bool,
pub(crate) allow_all_unix_sockets: bool,
#[serde(deserialize_with = "deserialize_paths")]
pub(crate) allow_unix_sockets: Vec<String>,
pub(crate) http_proxy_port: Option<u16>,
pub(crate) socks_proxy_port: Option<u16>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) enum AppContainerMode {
#[default]
Lpac,
Standard,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) enum WindowsBackend {
#[default]
AppContainer,
RestrictedUser,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub(crate) struct SandboxWindows {
pub(crate) backend: WindowsBackend,
pub(crate) app_container_mode: AppContainerMode,
pub(crate) allow_loopback: bool,
}
pub(crate) fn load_settings(
policy_paths: &[PathBuf],
format: PolicyFormat,
tool: &OsStr,
) -> Result<Settings> {
let mut merged = Value::Object(Map::new());
if policy_paths.is_empty() {
let mut document = String::new();
io::stdin()
.read_to_string(&mut document)
.map_err(|source| Error::PolicyIoFailed { source })
.context("policy stdin")?;
let value = parse_policy_document(&document, format).context("policy stdin")?;
merge_json(&mut merged, value);
} else {
for path in policy_paths {
log::debug!("config: {}", path.display());
let document = fs::read_to_string(path)
.map_err(|source| Error::PolicyIoFailed { source })
.with_context(|| format!("policy file {}", path.display()))?;
let value = parse_policy_document(&document, format)
.with_context(|| format!("policy file {}", path.display()))?;
merge_json(&mut merged, value);
}
}
if let Some(value) = read_executable_policy(tool, format)? {
merge_json(&mut merged, value);
}
parse_settings(merged).context("policy")
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn read_executable_policy(tool: &OsStr, format: PolicyFormat) -> Result<Option<Value>> {
let Some(exe) = resolve_executable(tool) else {
return Ok(None);
};
let Some(bytes) = read_xattr(&exe, EXECUTABLE_POLICY_XATTR)
.with_context(|| format!("executable policy {}", exe.display()))?
else {
return Ok(None);
};
let document = String::from_utf8(bytes)
.map_err(parse_failed)
.with_context(|| format!("executable policy {}", exe.display()))?;
let value = parse_policy_document(&document, format)
.with_context(|| format!("executable policy {}", exe.display()))?;
Ok(Some(value))
}
#[cfg(target_os = "windows")]
fn read_executable_policy(tool: &OsStr, format: PolicyFormat) -> Result<Option<Value>> {
let Some(exe) = resolve_executable(tool) else {
return Ok(None);
};
let mut stream = exe.clone().into_os_string();
stream.push(":");
stream.push(EXECUTABLE_POLICY_STREAM);
let bytes = match fs::read(PathBuf::from(stream)) {
Ok(bytes) => bytes,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(Error::PolicyIoFailed { source })
.with_context(|| format!("executable policy {}", exe.display()));
}
};
let document = String::from_utf8(bytes)
.map_err(parse_failed)
.with_context(|| format!("executable policy {}", exe.display()))?;
let value = parse_policy_document(&document, format)
.with_context(|| format!("executable policy {}", exe.display()))?;
Ok(Some(value))
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn read_executable_policy(_tool: &OsStr, _format: PolicyFormat) -> Result<Option<Value>> {
Ok(None)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
const EXECUTABLE_POLICY_XATTR: &str = "user.landstrip.policy";
#[cfg(target_os = "windows")]
const EXECUTABLE_POLICY_STREAM: &str = "landstrip.policy";
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn resolve_executable(tool: &OsStr) -> Option<PathBuf> {
let tool_path = Path::new(tool);
let has_location = tool_path
.parent()
.is_some_and(|parent| !parent.as_os_str().is_empty());
let candidate = if has_location {
is_executable_file(tool_path).then(|| tool_path.to_path_buf())?
} else {
let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var)
.map(|dir| dir.join(tool_path))
.find(|candidate| is_executable_file(candidate))?
};
candidate.canonicalize().ok()
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
}
#[cfg(target_os = "windows")]
fn resolve_executable(tool: &OsStr) -> Option<PathBuf> {
let tool_path = Path::new(tool);
let has_location = tool_path
.parent()
.is_some_and(|parent| !parent.as_os_str().is_empty());
let candidate = if has_location {
resolve_with_extensions(tool_path)?
} else {
let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var)
.find_map(|dir| resolve_with_extensions(&dir.join(tool_path)))?
};
candidate.canonicalize().ok()
}
#[cfg(target_os = "windows")]
fn resolve_with_extensions(base: &Path) -> Option<PathBuf> {
if base.extension().is_some() {
return base.is_file().then(|| base.to_path_buf());
}
let pathext = std::env::var_os("PATHEXT")
.unwrap_or_else(|| std::ffi::OsString::from(".COM;.EXE;.BAT;.CMD"));
std::env::split_paths(&pathext).find_map(|ext| {
let mut candidate = base.as_os_str().to_os_string();
candidate.push(ext);
let candidate = PathBuf::from(candidate);
candidate.is_file().then_some(candidate)
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn read_xattr(path: &Path, name: &str) -> std::result::Result<Option<Vec<u8>>, Error> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path =
CString::new(path.as_os_str().as_bytes()).map_err(|source| Error::PolicyIoFailed {
source: io::Error::new(io::ErrorKind::InvalidInput, source),
})?;
let c_name = CString::new(name).map_err(|source| Error::PolicyIoFailed {
source: io::Error::new(io::ErrorKind::InvalidInput, source),
})?;
let needed =
unsafe { platform_getxattr(c_path.as_ptr(), c_name.as_ptr(), std::ptr::null_mut(), 0) };
if needed < 0 {
return classify_xattr_error();
}
if needed == 0 {
return Ok(Some(Vec::new()));
}
#[allow(clippy::cast_sign_loss)]
let mut buffer = vec![0u8; needed as usize];
let got = unsafe {
platform_getxattr(
c_path.as_ptr(),
c_name.as_ptr(),
buffer.as_mut_ptr().cast(),
buffer.len(),
)
};
if got < 0 {
return classify_xattr_error();
}
#[allow(clippy::cast_sign_loss)]
buffer.truncate(got as usize);
Ok(Some(buffer))
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn classify_xattr_error() -> std::result::Result<Option<Vec<u8>>, Error> {
#[cfg(target_os = "linux")]
let missing = libc::ENODATA;
#[cfg(target_os = "macos")]
let missing = libc::ENOATTR;
let error = io::Error::last_os_error();
match error.raw_os_error() {
Some(code) if code == missing || code == libc::ENOENT => Ok(None),
_ => Err(Error::PolicyIoFailed { source: error }),
}
}
#[cfg(target_os = "linux")]
unsafe fn platform_getxattr(
path: *const libc::c_char,
name: *const libc::c_char,
value: *mut libc::c_void,
size: libc::size_t,
) -> libc::ssize_t {
unsafe { libc::getxattr(path, name, value, size) }
}
#[cfg(target_os = "macos")]
unsafe fn platform_getxattr(
path: *const libc::c_char,
name: *const libc::c_char,
value: *mut libc::c_void,
size: libc::size_t,
) -> libc::ssize_t {
unsafe { libc::getxattr(path, name, value, size, 0, 0) }
}
fn parse_policy_document(
document: &str,
format: PolicyFormat,
) -> std::result::Result<Value, Error> {
match format {
PolicyFormat::Json => serde_json::from_str(document).map_err(parse_failed),
PolicyFormat::Yaml => serde_yml::from_str(document).map_err(parse_failed),
}
}
fn parse_settings(document: Value) -> std::result::Result<Settings, Error> {
serde_json::from_value(document).map_err(parse_failed)
}
fn parse_failed(source: impl StdError + Send + Sync + 'static) -> Error {
Error::PolicyParseFailed {
source: Box::new(source),
}
}
fn deserialize_paths<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let input = Option::<PathInput>::deserialize(deserializer)?;
Ok(match input {
Some(PathInput::List(paths)) => paths,
Some(PathInput::Lines(lines)) => lines
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect(),
None => Vec::new(),
})
}
#[derive(Deserialize)]
#[serde(untagged)]
enum PathInput {
List(Vec<String>),
Lines(String),
}
fn merge_json(base: &mut Value, overlay: Value) {
match (base, overlay) {
(Value::Object(base), Value::Object(overlay)) => {
for (key, value) in overlay {
merge_json(base.entry(key).or_insert(Value::Null), value);
}
}
(Value::Array(base), Value::Array(overlay)) => {
for value in overlay {
if !base.contains(&value) {
base.push(value);
}
}
}
(base, overlay) => {
*base = overlay;
}
}
}