use super::error::WasiError;
use super::sandbox::{expand_env_pattern, SandboxConfig, SandboxValidator};
use sen_plugin_api::Capabilities;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct WasiConfig {
pub working_directory: Option<PathBuf>,
pub follow_symlinks: bool,
pub require_existence: bool,
pub args: Vec<String>,
pub program_name: String,
}
impl Default for WasiConfig {
fn default() -> Self {
Self {
working_directory: None,
follow_symlinks: true,
require_existence: true,
args: Vec::new(),
program_name: "plugin".to_string(),
}
}
}
#[derive(Debug)]
pub struct WasiConfigurer {
config: WasiConfig,
capabilities: Option<Capabilities>,
}
impl WasiConfigurer {
pub fn new() -> Self {
Self {
config: WasiConfig::default(),
capabilities: None,
}
}
pub fn with_capabilities(mut self, capabilities: &Capabilities) -> Self {
self.capabilities = Some(capabilities.clone());
self
}
pub fn with_working_directory(mut self, path: PathBuf) -> Self {
self.config.working_directory = Some(path);
self
}
pub fn with_args(mut self, args: Vec<String>) -> Self {
self.config.args = args;
self
}
pub fn with_program_name(mut self, name: impl Into<String>) -> Self {
self.config.program_name = name.into();
self
}
pub fn follow_symlinks(mut self, follow: bool) -> Self {
self.config.follow_symlinks = follow;
self
}
pub fn require_existence(mut self, require: bool) -> Self {
self.config.require_existence = require;
self
}
pub fn build(self) -> Result<WasiSpec, WasiError> {
let working_dir = self
.config
.working_directory
.ok_or(WasiError::WorkingDirectoryNotSet)?;
let validator = SandboxValidator::new(SandboxConfig {
working_directory: working_dir.clone(),
follow_symlinks: self.config.follow_symlinks,
require_existence: self.config.require_existence,
});
let caps = self.capabilities.unwrap_or_default();
let mut spec = WasiSpec::new(self.config.program_name, self.config.args);
for pattern in &caps.fs_read {
let resolved = validator.validate_directory(&pattern.pattern)?;
let guest_path = derive_guest_path(&pattern.pattern);
spec.preopened_dirs.push(PreopenedDir {
host_path: resolved,
guest_path,
writable: false,
});
}
for pattern in &caps.fs_write {
let resolved = validator.validate_directory(&pattern.pattern)?;
let guest_path = derive_guest_path(&pattern.pattern);
if let Some(existing) = spec
.preopened_dirs
.iter_mut()
.find(|d| d.host_path == resolved)
{
existing.writable = true;
} else {
spec.preopened_dirs.push(PreopenedDir {
host_path: resolved,
guest_path,
writable: true,
});
}
}
for pattern in &caps.env_read {
let vars = expand_env_pattern(pattern)?;
for (key, value) in vars {
if !spec.env_vars.iter().any(|(k, _)| k == &key) {
spec.env_vars.push((key, value));
}
}
}
spec.inherit_stdin = caps.stdio.stdin;
spec.inherit_stdout = caps.stdio.stdout;
spec.inherit_stderr = caps.stdio.stderr;
if !caps.net.is_empty() {
tracing::warn!(
"Network capabilities declared but not supported in WASI Preview 1. \
Network access will be denied."
);
}
Ok(spec)
}
}
impl Default for WasiConfigurer {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct WasiSpec {
pub program_name: String,
pub args: Vec<String>,
pub preopened_dirs: Vec<PreopenedDir>,
pub env_vars: Vec<(String, String)>,
pub inherit_stdin: bool,
pub inherit_stdout: bool,
pub inherit_stderr: bool,
}
impl WasiSpec {
pub fn new(program_name: String, args: Vec<String>) -> Self {
Self {
program_name,
args,
preopened_dirs: Vec::new(),
env_vars: Vec::new(),
inherit_stdin: false,
inherit_stdout: false,
inherit_stderr: false,
}
}
pub fn build_p1_ctx(self) -> Result<wasmtime_wasi::preview1::WasiP1Ctx, WasiError> {
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};
let mut builder = WasiCtxBuilder::new();
let mut all_args = vec![self.program_name];
all_args.extend(self.args);
builder.args(&all_args);
for (key, value) in &self.env_vars {
builder.env(key, value);
}
if self.inherit_stdin {
builder.inherit_stdin();
}
if self.inherit_stdout {
builder.inherit_stdout();
}
if self.inherit_stderr {
builder.inherit_stderr();
}
for dir in &self.preopened_dirs {
let dir_perms = if dir.writable {
DirPerms::all()
} else {
DirPerms::READ
};
let file_perms = if dir.writable {
FilePerms::all()
} else {
FilePerms::READ
};
builder
.preopened_dir(&dir.host_path, &dir.guest_path, dir_perms, file_perms)
.map_err(|e| WasiError::PreopenFailed {
path: dir.host_path.clone(),
source: std::io::Error::other(e.to_string()),
})?;
}
Ok(builder.build_p1())
}
pub fn build_ctx(self) -> Result<wasmtime_wasi::WasiCtx, WasiError> {
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};
let mut builder = WasiCtxBuilder::new();
let mut all_args = vec![self.program_name.clone()];
all_args.extend(self.args.clone());
builder.args(&all_args);
for (key, value) in &self.env_vars {
builder.env(key, value);
}
if self.inherit_stdin {
builder.inherit_stdin();
}
if self.inherit_stdout {
builder.inherit_stdout();
}
if self.inherit_stderr {
builder.inherit_stderr();
}
for dir in &self.preopened_dirs {
let dir_perms = if dir.writable {
DirPerms::all()
} else {
DirPerms::READ
};
let file_perms = if dir.writable {
FilePerms::all()
} else {
FilePerms::READ
};
builder
.preopened_dir(&dir.host_path, &dir.guest_path, dir_perms, file_perms)
.map_err(|e| WasiError::PreopenFailed {
path: dir.host_path.clone(),
source: std::io::Error::other(e.to_string()),
})?;
}
Ok(builder.build())
}
pub fn build_ctx_with_table(
self,
) -> Result<(wasmtime_wasi::WasiCtx, wasmtime_wasi::ResourceTable), WasiError> {
let ctx = self.build_ctx()?;
let table = wasmtime_wasi::ResourceTable::new();
Ok((ctx, table))
}
pub fn has_fs_access(&self) -> bool {
!self.preopened_dirs.is_empty()
}
pub fn has_write_access(&self) -> bool {
self.preopened_dirs.iter().any(|d| d.writable)
}
pub fn permission_summary(&self) -> String {
let mut parts = Vec::new();
if !self.preopened_dirs.is_empty() {
let read_paths: Vec<_> = self
.preopened_dirs
.iter()
.filter(|d| !d.writable)
.map(|d| d.guest_path.as_str())
.collect();
let write_paths: Vec<_> = self
.preopened_dirs
.iter()
.filter(|d| d.writable)
.map(|d| d.guest_path.as_str())
.collect();
if !read_paths.is_empty() {
parts.push(format!("fs_read: [{}]", read_paths.join(", ")));
}
if !write_paths.is_empty() {
parts.push(format!("fs_write: [{}]", write_paths.join(", ")));
}
}
if !self.env_vars.is_empty() {
let keys: Vec<_> = self.env_vars.iter().map(|(k, _)| k.as_str()).collect();
parts.push(format!("env: [{}]", keys.join(", ")));
}
let mut stdio = Vec::new();
if self.inherit_stdin {
stdio.push("stdin");
}
if self.inherit_stdout {
stdio.push("stdout");
}
if self.inherit_stderr {
stdio.push("stderr");
}
if !stdio.is_empty() {
parts.push(format!("stdio: [{}]", stdio.join(", ")));
}
if parts.is_empty() {
"none".to_string()
} else {
parts.join(", ")
}
}
}
#[derive(Debug, Clone)]
pub struct PreopenedDir {
pub host_path: PathBuf,
pub guest_path: String,
pub writable: bool,
}
fn derive_guest_path(pattern: &str) -> String {
if let Some(suffix) = pattern
.strip_prefix("./")
.or_else(|| pattern.strip_prefix("~/"))
{
format!("/{}", suffix)
} else if pattern.starts_with('/') {
pattern.to_string()
} else {
format!("/{}", pattern)
}
}
#[cfg(test)]
mod tests {
use super::*;
use sen_plugin_api::{PathPattern, StdioCapability};
#[test]
fn test_derive_guest_path() {
assert_eq!(derive_guest_path("./data"), "/data");
assert_eq!(derive_guest_path("~/config"), "/config");
assert_eq!(derive_guest_path("/tmp/myapp"), "/tmp/myapp");
assert_eq!(derive_guest_path("data"), "/data");
}
#[test]
fn test_wasi_spec_summary() {
let mut spec = WasiSpec::new("test".into(), vec![]);
spec.preopened_dirs.push(PreopenedDir {
host_path: PathBuf::from("/data"),
guest_path: "/data".into(),
writable: false,
});
spec.env_vars.push(("HOME".into(), "/home/user".into()));
spec.inherit_stdout = true;
let summary = spec.permission_summary();
assert!(summary.contains("fs_read"));
assert!(summary.contains("/data"));
assert!(summary.contains("env"));
assert!(summary.contains("HOME"));
assert!(summary.contains("stdout"));
}
#[test]
fn test_configurer_requires_working_dir() {
let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
let result = WasiConfigurer::new().with_capabilities(&caps).build();
assert!(matches!(result, Err(WasiError::WorkingDirectoryNotSet)));
}
#[test]
fn test_empty_capabilities() {
let caps = Capabilities::none();
let spec = WasiConfigurer::new()
.with_capabilities(&caps)
.with_working_directory(PathBuf::from("/tmp"))
.require_existence(false)
.build()
.unwrap();
assert!(!spec.has_fs_access());
assert!(!spec.inherit_stdin);
assert!(!spec.inherit_stdout);
assert!(!spec.inherit_stderr);
}
#[test]
fn test_stdio_configuration() {
let caps = Capabilities::default().with_stdio(StdioCapability::stdout_stderr());
let spec = WasiConfigurer::new()
.with_capabilities(&caps)
.with_working_directory(PathBuf::from("/tmp"))
.require_existence(false)
.build()
.unwrap();
assert!(!spec.inherit_stdin);
assert!(spec.inherit_stdout);
assert!(spec.inherit_stderr);
}
}