initial commit

Signed-off-by: sisungo <[email protected]>
This commit is contained in:
2026-05-16 12:15:55 +08:00
commit c8bf95ec9c
13 changed files with 690 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
/Cargo.lock
+3
View File
@@ -0,0 +1,3 @@
[workspace]
members = ["bin/semios_sandbox_exec", "lib/semios_sandbox","lib/semios_sandbox_low","lib/semios_sandbox_parser"]
resolver = "3"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "semios_sandbox_exec"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", default-features = false, features = ["std", "help", "usage", "derive"] }
semios_sandbox = { path = "../../lib/semios_sandbox" }
+58
View File
@@ -0,0 +1,58 @@
use clap::Parser;
use semios_sandbox::vm::Vm;
use std::{ffi::OsString, os::unix::process::CommandExt, path::PathBuf};
macro_rules! errx {
($($arg:tt)*) => {
eprintln!("semios_sandbox_exec: {}", format!($($arg)*));
std::process::exit(1);
};
}
#[derive(Debug, Clone, Parser)]
struct Cli {
#[arg(short, long)]
rule: PathBuf,
#[arg(long)]
arg0: Option<OsString>,
cmd: Vec<OsString>,
}
fn main() {
let cli = Cli::parse();
let rule = match std::fs::read_to_string(&cli.rule) {
Ok(x) => x,
Err(err) => {
errx!("failed to read rule \"{}\": {}", cli.rule.display(), err);
}
};
let ast = match semios_sandbox::parser::parse(&rule) {
Ok(x) => x,
Err(err) => {
errx!("failed to parse rule \"{}\": {}", cli.rule.display(), err);
}
};
let mut vm = Vm::new();
if let Err(err) = vm.exec(&ast) {
errx!("failed to execute rule \"{}\": {}", cli.rule.display(), err);
};
if let Err(err) = vm.finish() {
errx!("failed to apply rules: {err}");
}
let Some(path) = cli.cmd.first() else {
errx!("no program specified");
};
let arg0 = cli.arg0.as_ref().unwrap_or(path);
let args = &cli.cmd[1..];
let err = std::process::Command::new(path)
.arg0(arg0)
.args(args)
.exec();
errx!("failed to execute \"{}\": {}", path.display(), err);
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "semios_sandbox"
version = "0.1.0"
edition = "2024"
[dependencies]
semios_sandbox_parser = { path = "../semios_sandbox_parser" }
semios_sandbox_low = { path = "../semios_sandbox_low" }
rustc-hash = "2"
+4
View File
@@ -0,0 +1,4 @@
pub mod vm;
pub use semios_sandbox_low as low;
pub use semios_sandbox_parser as parser;
+254
View File
@@ -0,0 +1,254 @@
//! Virtual machine for executing rule code.
use rustc_hash::FxHashMap;
use semios_sandbox_low::{FilesystemOps, FilesystemRule, Rules, apply};
use semios_sandbox_parser::{Expr, LitPart, Stmt};
use std::{
fmt::{Debug, Display},
path::PathBuf,
};
pub struct Vm {
native_mods: Vec<Box<dyn NativeMod>>,
}
impl Vm {
pub fn new() -> Self {
Self {
native_mods: vec![Box::new(EnvMod), Box::new(SandboxMod::new())],
}
}
pub fn exec(&mut self, ast: &[Stmt]) -> Result<(), VmError> {
let mut local_scope = LocalScope::new();
for i in ast {
match i {
Stmt::Let(decl) => {
local_scope.declare(&decl.name.0, self.eval(&local_scope, decl.value.clone())?);
}
Stmt::Expr(expr) => {
self.eval(&local_scope, expr.clone())?;
}
}
}
Ok(())
}
pub fn finish(self) -> Result<(), VmError> {
for mut i in self.native_mods {
i.finish()
.map_err(|err| VmError::NativeError("[anonymous] finish".into(), err))?;
}
Ok(())
}
fn eval(&mut self, local_scope: &LocalScope, expr: Expr) -> Result<Value, VmError> {
match expr {
Expr::Lit(x) => {
let Some(parts) = semios_sandbox_parser::lit(&x) else {
return Err(VmError::LitSyntaxError);
};
let mut s = String::new();
for i in parts {
match i {
LitPart::Direct(y) => s.push_str(&y),
LitPart::Variable(key) => match local_scope.get(&key.0)? {
Value::Lit(z) => s.push_str(&z),
_ => return Err(VmError::VarAbsent(key.0)),
},
}
}
Ok(Value::Lit(s))
}
Expr::Var(x) => local_scope.get(&x.0),
Expr::Call(x) => {
let mut args = Vec::with_capacity(x.args.len());
for i in x.args {
args.push(self.eval(local_scope, i)?);
}
for i in &mut self.native_mods {
match i.invoke(&x.name.0, &args) {
Ok(ret) => return Ok(ret),
Err(NativeModError::MethodAbsent) => continue,
Err(NativeModError::StdError(err)) => {
return Err(VmError::NativeError(x.name.0, err));
}
}
}
Err(VmError::VarAbsent(x.name.0))
}
Expr::Set(x) => {
let mut ret = Vec::with_capacity(x.len());
for i in x {
ret.push(self.eval(local_scope, i)?);
}
Ok(Value::Set(ret))
}
}
}
}
impl Debug for Vm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vm").field("native_mods", &()).finish()
}
}
#[derive(Debug, Clone, Default)]
struct LocalScope {
map: FxHashMap<String, Value>,
}
impl LocalScope {
fn new() -> Self {
Self::default()
}
fn declare(&mut self, key: &str, val: Value) {
self.map.insert(key.into(), val);
}
fn get(&self, key: &str) -> Result<Value, VmError> {
self.map
.get(key)
.cloned()
.ok_or_else(|| VmError::MethodAbsent(key.into()))
}
}
pub trait NativeMod {
fn invoke(&mut self, method: &str, args: &[Value]) -> Result<Value, NativeModError>;
fn finish(&mut self) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub enum Value {
Set(Vec<Value>),
Lit(String),
}
#[derive(Debug)]
pub enum NativeModError {
MethodAbsent,
StdError(Box<dyn std::error::Error>),
}
impl<E: std::error::Error + 'static> From<E> for NativeModError {
fn from(value: E) -> Self {
Self::StdError(Box::new(value))
}
}
impl Display for NativeModError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MethodAbsent => write!(f, "method is absent"),
Self::StdError(err) => write!(f, "{err}"),
}
}
}
#[derive(Debug)]
pub enum VmError {
MethodAbsent(String),
VarAbsent(String),
NativeError(String, Box<dyn std::error::Error>),
LitSyntaxError,
}
impl Display for VmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MethodAbsent(method) => write!(f, "method is absent: {method}"),
Self::VarAbsent(var) => write!(f, "variable is absent: {var}"),
Self::NativeError(method, err) => write!(f, "method `{method}` failed: {err}"),
Self::LitSyntaxError => write!(f, "literal syntax error"),
}
}
}
impl std::error::Error for VmError {}
struct EnvMod;
impl NativeMod for EnvMod {
fn invoke(&mut self, method: &str, args: &[Value]) -> Result<Value, NativeModError> {
if method == "env" {
let Some(Value::Lit(key)) = args.get(0) else {
return Err(NativeModError::MethodAbsent);
};
if let Ok(val) = std::env::var(key) {
Ok(Value::Lit(val))
} else if let Some(Value::Lit(default)) = args.get(1) {
Ok(Value::Lit(default.into()))
} else {
Err(NativeModError::StdError(Box::from(format!(
"Environment `{key}` not found"
))))
}
} else {
Err(NativeModError::MethodAbsent)
}
}
}
struct SandboxMod {
rules: Rules,
}
impl SandboxMod {
fn new() -> Self {
Self {
rules: Rules::new(),
}
}
fn fs_allow(&mut self, path: &str, sops: &[String]) -> Result<Value, NativeModError> {
let fs_op = |x| match x {
"read" => Ok(FilesystemOps::READ),
"write" => Ok(FilesystemOps::WRITE),
"exec" => Ok(FilesystemOps::EXEC),
"browse" => Ok(FilesystemOps::BROWSE),
"create_reg" => Ok(FilesystemOps::CREATE_REG),
"create_dir" => Ok(FilesystemOps::CREATE_DIR),
"create_dev" => Ok(FilesystemOps::CREATE_DEV),
"create_sym" => Ok(FilesystemOps::CREATE_SYM),
"create_ipc" => Ok(FilesystemOps::CREATE_IPC),
"remove_file" => Ok(FilesystemOps::REMOVE_FILE),
"remove_dir" => Ok(FilesystemOps::REMOVE_DIR),
"control_io" => Ok(FilesystemOps::CONTROL_IO),
"refer" => Ok(FilesystemOps::REFER),
other => Err(NativeModError::StdError(Box::from(format!(
"unrecognized filesystem op `{other}`"
)))),
};
let mut ops = FilesystemOps::empty();
for sop in sops {
ops |= fs_op(sop)?;
}
self.rules.filesystem.push(FilesystemRule {
path: PathBuf::from(path),
ops,
});
Ok(Value::Lit(String::new()))
}
}
impl NativeMod for SandboxMod {
fn invoke(&mut self, method: &str, args: &[Value]) -> Result<Value, NativeModError> {
match (method, args) {
("fs_allow", [Value::Lit(path), Value::Set(ops)]) => {
let mut rops = Vec::with_capacity(ops.len());
for op in ops {
if let Value::Lit(op) = op {
rops.push(op.into());
} else {
return Err(NativeModError::StdError(Box::from("invalid arguments")));
}
}
self.fs_allow(path, &rops)
}
("fs_allow", [Value::Lit(path), Value::Lit(op)]) => self.fs_allow(path, &[op.into()]),
_ => Err(NativeModError::MethodAbsent),
}
}
fn finish(&mut self) -> Result<(), Box<dyn std::error::Error>> {
apply(self.rules.clone()).map_err(Box::from)
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "semios_sandbox_low"
version = "0.1.0"
edition = "2024"
[dependencies]
bitflags = "2"
enumflags2 = "0.7"
landlock = "0.4"
+73
View File
@@ -0,0 +1,73 @@
use bitflags::bitflags;
use std::path::PathBuf;
#[cfg_attr(target_os = "linux", path = "linux.rs")]
mod imp;
/// Low-level sandbox rules.
#[derive(Debug, Clone)]
pub struct Rules {
pub filesystem: Vec<FilesystemRule>,
}
impl Rules {
pub fn new() -> Self {
Self {
filesystem: Vec::new(),
}
}
}
/// Filesystem rule set.
#[derive(Debug, Clone)]
pub struct FilesystemRule {
pub path: PathBuf,
pub ops: FilesystemOps,
}
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct FilesystemOps: u32 {
/// Read a file.
const READ = 1;
/// Write to a file.
const WRITE = 2;
/// Execute a file.
const EXEC = 4;
/// Read / Browse a directory.
const BROWSE = 8;
/// Create a regular file.
const CREATE_REG = 16;
/// Create a directory.
const CREATE_DIR = 32;
/// Create a symbolic link.
const CREATE_SYM = 64;
/// Create a IPC primitive.
const CREATE_IPC = 128;
/// Create a device file.
const CREATE_DEV = 256;
/// Removes a directory.
const REMOVE_DIR = 512;
/// Removes a file.
const REMOVE_FILE = 1024;
/// Link or rename a file from or to a different directory.
const REFER = 2048;
/// Controls device input / output.
const CONTROL_IO = 4096;
}
}
pub fn apply(rules: Rules) -> Result<(), Box<dyn std::error::Error>> {
imp::apply_rules(rules)
}
+68
View File
@@ -0,0 +1,68 @@
use crate::{FilesystemOps, Rules};
use enumflags2::BitFlag;
use landlock::{
ABI, Access, AccessFs, BitFlags, RulesetAttr, RulesetCreatedAttr, path_beneath_rules,
};
pub fn apply_rules(rules: Rules) -> Result<(), Box<dyn std::error::Error>> {
let mut ruleset = landlock::Ruleset::default()
.handle_access(AccessFs::from_all(ABI::V6))?
.create()?;
for i in rules.filesystem.iter() {
ruleset = ruleset.add_rules(path_beneath_rules(
[i.path.clone()],
landlock_fs_access(i.ops),
))?;
}
ruleset.restrict_self()?;
Ok(())
}
fn landlock_fs_access(x: FilesystemOps) -> BitFlags<AccessFs> {
let mut ret = AccessFs::empty();
if x.contains(FilesystemOps::READ) {
ret |= AccessFs::ReadFile;
}
if x.contains(FilesystemOps::WRITE) {
ret |= AccessFs::WriteFile | AccessFs::Truncate;
}
if x.contains(FilesystemOps::EXEC) {
ret |= AccessFs::Execute;
}
if x.contains(FilesystemOps::BROWSE) {
ret |= AccessFs::ReadDir;
}
if x.contains(FilesystemOps::CREATE_REG) {
ret |= AccessFs::MakeReg;
}
if x.contains(FilesystemOps::CREATE_DIR) {
ret |= AccessFs::MakeDir;
}
if x.contains(FilesystemOps::CREATE_SYM) {
ret |= AccessFs::MakeSym;
}
if x.contains(FilesystemOps::CREATE_DEV) {
ret |= AccessFs::MakeBlock | AccessFs::MakeChar;
}
if x.contains(FilesystemOps::CREATE_IPC) {
ret |= AccessFs::MakeSock | AccessFs::MakeFifo;
}
if x.contains(FilesystemOps::REMOVE_FILE) {
ret |= AccessFs::RemoveFile;
}
if x.contains(FilesystemOps::REMOVE_DIR) {
ret |= AccessFs::RemoveDir;
}
if x.contains(FilesystemOps::CONTROL_IO) {
ret |= AccessFs::IoctlDev;
}
if x.contains(FilesystemOps::REFER) {
ret |= AccessFs::Refer;
}
ret
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "semios_sandbox_parser"
version = "0.1.0"
edition = "2024"
[dependencies]
peg = "0.8"
unicode-xid = "0.2"
+179
View File
@@ -0,0 +1,179 @@
use unicode_xid::UnicodeXID;
peg::parser! {
grammar sandbox() for str {
pub rule program() -> Vec<Stmt>
= _* stmt:stmt() ** (_*) _* { stmt }
rule stmt() -> Stmt
= x:let_stmt() { Stmt::Let(x) }
/ x:expr() _* ";" { Stmt::Expr(x) }
rule let_stmt() -> StmtLet
= "let" _+ name:ident() _* "=" _* value:expr() _* ";" { StmtLet { name, value } }
rule expr() -> Expr
= x:expr_call() { Expr::Call(x) }
/ x:lit() { Expr::Lit(x) }
/ x:ident() { Expr::Var(x) }
/ x:expr_set() { Expr::Set(x) }
rule expr_call() -> ExprCall
= name:ident() _* "(" _* args:expr() ** (_* "," _*) _* ")" { ExprCall { name, args } }
rule expr_set() -> Vec<Expr>
= "{" _* args:expr() ** (_* "," _*) _* "}" { args }
rule ident() -> Ident
= f:([x if UnicodeXID::is_xid_start(x)]) s:([x if UnicodeXID::is_xid_continue(x)])*
{ Ident(format!("{}{}", f, s.iter().collect::<String>())) }
rule lit() -> String
= lit_str()
/ lit_int()
rule lit_int() -> String
= x:['0'..='9']+ { x.into_iter().collect() }
/ "0x" x:['a'..='f' | 'A'..='F' | '0'..='9']+ { "0x".chars().chain(x.into_iter()).collect() }
/ "0o" x:['0'..='7']+ { "0o".chars().chain(x.into_iter()).collect() }
/ "0b" x:['0'..='1']+ { "0b".chars().chain(x.into_iter()).collect() }
rule lit_str() -> String
= "\"" x:lit_str_double_part()* "\"" { x.iter().collect() }
/ "'" x:lit_str_single_part()* "'" { x.iter().collect() }
rule lit_str_double_part() -> char
= x:lit_str_part_escape() { x }
/ x:[^ '"' | '\n' | '\r'] { x }
rule lit_str_single_part() -> char
= x:lit_str_part_escape() { x }
/ x:[^ '\'' | '\n' | '\r'] { x }
rule lit_str_part_escape() -> char
= "\\\\" { '\\' }
/ "\\'" { '\'' }
/ "\\\"" { '"' }
/ "\\n" { '\n' }
/ "\\r" { '\r' }
/ "\\t" { '\t' }
rule comment()
= "//" [^ '\n']*
rule _()
= [' ' | '\t' | '\n']
/ comment()
}
}
#[derive(Debug, Clone)]
pub struct Ident(pub String);
#[derive(Debug, Clone)]
pub enum Stmt {
Let(StmtLet),
Expr(Expr),
}
#[derive(Debug, Clone)]
pub struct StmtLet {
pub name: Ident,
pub value: Expr,
}
#[derive(Debug, Clone)]
pub enum Expr {
Call(ExprCall),
Lit(String),
Var(Ident),
Set(Vec<Expr>),
}
#[derive(Debug, Clone)]
pub struct ExprCall {
pub name: Ident,
pub args: Vec<Expr>,
}
pub use sandbox::program as parse;
#[derive(Debug, Clone)]
pub enum LitPart {
Direct(String),
Variable(Ident),
}
pub fn lit(s: &str) -> Option<Vec<LitPart>> {
let mut ret = Vec::new();
let mut current = String::new();
let mut chars = s.chars().peekable();
let mut in_ident = false;
while let Some(c) = chars.next() {
if c == '{' {
if let Some(&next_c) = chars.peek() {
if next_c == '{' {
current.push('{');
chars.next();
continue;
}
}
if !in_ident {
if !current.is_empty() {
ret.push(LitPart::Direct(current.clone()));
current.clear();
}
in_ident = true;
let mut ident = String::new();
let mut closed = false;
while let Some(ch) = chars.next() {
if ch == '}' {
closed = true;
break;
}
ident.push(ch);
}
if !closed {
return None;
}
if ident.is_empty() {
return None;
}
ret.push(LitPart::Variable(Ident(ident)));
} else {
return None;
}
} else if c == '}' {
if let Some(&next_c) = chars.peek() {
if next_c == '}' {
current.push('}');
chars.next();
continue;
}
}
return None;
} else {
if in_ident {
return None;
}
current.push(c);
}
}
if in_ident {
return None;
}
if !current.is_empty() {
ret.push(LitPart::Direct(current));
}
Some(ret)
}
+15
View File
@@ -0,0 +1,15 @@
// Pre-defined Sandbox Rule: APPLICATION
let home = env('HOME');
// Allow linking to system libraries.
fs_allow('/lib', { "read", "exec", "browse" });
fs_allow('/lib64', { "read", "exec", "browse" });
fs_allow('/usr/lib', { "read", "exec", "browse" });
// Allow executing system commands.
fs_allow('/bin', { "read", "browse", "exec" });
fs_allow('/usr/bin', { "read", "browse", "exec" });
// Allow accessing user home.
fs_allow(home, { "read", "write", "browse" });