From 0d0bf668c0acf022e5186436884272db81467bcc Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 13:48:21 +0800 Subject: [PATCH 1/3] [CTX-1294] feat: add restricted Lua plugin settings --- Cargo.lock | 1 + crates/bitty-lua/src/lib.rs | 87 +- crates/bitty-lua/src/plain_config.rs | 914 ++++++++++++++++++ crates/bitty-lua/src/stdlib.rs | 33 + crates/bitty-runtime/Cargo.toml | 3 + .../bitty-runtime/src/plugin_runtime/mod.rs | 104 +- .../src/plugin_runtime/plugin_settings.rs | 590 ++++++++++- .../tests/plugin_settings_file.rs | 96 ++ 8 files changed, 1813 insertions(+), 15 deletions(-) create mode 100644 crates/bitty-lua/src/plain_config.rs diff --git a/Cargo.lock b/Cargo.lock index 05df8cc1c..dc8d4baf4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -348,6 +348,7 @@ dependencies = [ "bitty-winjob", "nix 0.31.3", "rustix 1.1.5", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/bitty-lua/src/lib.rs b/crates/bitty-lua/src/lib.rs index 2d180849f..7825d1cd4 100644 --- a/crates/bitty-lua/src/lib.rs +++ b/crates/bitty-lua/src/lib.rs @@ -59,12 +59,13 @@ use std::path::PathBuf; use std::rc::Rc; use std::time::{Duration, Instant}; -use phodopus::{Closure, Executor, ExecutorMode, Fuel, Lua, StashedExecutor}; +use phodopus::{Closure, Executor, ExecutorMode, Fuel, Lua, StashedExecutor, Value, Variadic}; pub mod config; pub mod error; pub mod gate; pub mod host; +pub mod plain_config; mod stdlib; pub mod store; pub mod ui; @@ -96,6 +97,7 @@ pub use host::{ env_key_shape_ok, parse_fs_write_append, parse_theme_table, validate_env_key, validate_fs_content, validate_fs_list_max, validate_fs_path, validate_image_path, }; +pub use plain_config::{ConfigLuaVm, PlainConfigError, PlainConfigLimits, PlainConfigValue}; pub use ui::{UI_MAX_DEPTH, UI_MAX_TEXT_BYTES, UI_SLOTS, UI_V1_NODE_KINDS, UiNode, UiSlot}; // ── RC budgets (aligned with bitty-plugin-host/src/event.rs) ─────────────── @@ -624,10 +626,41 @@ impl LuaVm { // and not charged against the quota; `fuel_limit` stays unset so the // host `SLICE_FUEL` stepping loop remains the authoritative RC-1 // instruction policy. + let lua = Self::build_plugin_lua(memory_limit); + Self::from_lua( + id, + instruction_budget, + wall_budget_ms, + warning_ms, + memory_limit, + lua, + ) + } + + fn build_plugin_lua(memory_limit: usize) -> Lua { let mut builder = Lua::builder(); builder.memory_limit(memory_limit); let mut lua = builder.build(); stdlib::install_retained_stdlib(&mut lua); + lua + } + + pub(crate) fn build_config_lua(memory_limit: usize) -> Lua { + let mut builder = Lua::builder(); + builder.memory_limit(memory_limit); + let mut lua = builder.build(); + stdlib::install_config_stdlib(&mut lua); + lua + } + + fn from_lua( + id: impl Into, + instruction_budget: u64, + wall_budget_ms: u64, + warning_ms: u64, + memory_limit: usize, + lua: Lua, + ) -> Self { Self { id: id.into(), lua, @@ -654,6 +687,58 @@ impl LuaVm { } } + pub(crate) fn with_config_budgets( + id: impl Into, + instruction_budget: u64, + wall_budget_ms: u64, + warning_ms: u64, + memory_limit: usize, + ) -> Self { + assert!(instruction_budget > 0, "instruction budget must be > 0"); + assert!(wall_budget_ms > 0, "wall budget must be > 0"); + assert!(memory_limit > 0, "memory limit must be > 0"); + let lua = Self::build_config_lua(memory_limit); + Self::from_lua( + id, + instruction_budget, + wall_budget_ms, + warning_ms, + memory_limit, + lua, + ) + } + + pub(crate) fn take_plain_result( + &mut self, + stashed: StashedExecutor, + limits: plain_config::PlainConfigLimits, + ) -> Result { + let mode = self.lua.enter(|ctx| ctx.fetch(&stashed).mode()); + if mode != ExecutorMode::Result { + return Err(plain_config::PlainConfigError::Lua( + "configuration did not return a value".to_string(), + )); + } + self.lua.enter(|ctx| { + let exec = ctx.fetch(&stashed); + match exec.take_result::>>(ctx) { + Ok(Ok(Variadic(values))) if values.len() == 1 => { + if !matches!(values[0], Value::Table(_)) { + return Err(plain_config::PlainConfigError::Shape( + "configuration must return exactly one table".to_string(), + )); + } + plain_config::capture_plain_value(ctx, values[0], limits) + } + Ok(Ok(Variadic(_))) => Err(plain_config::PlainConfigError::Shape( + "configuration must return exactly one table".to_string(), + )), + Ok(Err(error)) => Err(plain_config::PlainConfigError::Lua(format!("{error}"))), + Err(error) => Err(plain_config::PlainConfigError::Lua(format!("{error:?}"))), + } + }) + } + /// Plugin id. #[must_use] pub fn id(&self) -> &str { diff --git a/crates/bitty-lua/src/plain_config.rs b/crates/bitty-lua/src/plain_config.rs new file mode 100644 index 000000000..643cf2335 --- /dev/null +++ b/crates/bitty-lua/src/plain_config.rs @@ -0,0 +1,914 @@ +//! Generic, host-free plain-data evaluation for per-plugin configuration. + +use std::collections::{BTreeMap, BTreeSet}; + +use phodopus::{ + Context, Table, Value, + compiler::{ + interning::BasicInterner, + parse_chunk, + parser::{ + AssignmentTarget, Block, ConstructorField, Expression, FieldSuffix, ForStatement, + HeadExpression, PrimaryExpression, RecordKey, SimpleExpression, Statement, + SuffixedExpression, TableConstructor, + }, + }, +}; + +use crate::{DriveOutcome, LuaVm, VmError}; + +/// Owned data accepted at the plugin configuration boundary. +#[derive(Debug, Clone, PartialEq)] +pub enum PlainConfigValue { + /// Boolean scalar. + Bool(bool), + /// Exact Lua integer. + Integer(i64), + /// Finite Lua number. + Number(f64), + /// UTF-8 string. + String(String), + /// String-keyed object. + Table(BTreeMap), + /// Dense, one-based Lua array. + Array(Vec), +} + +/// Bounds applied to source, VM execution, and owned output extraction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlainConfigLimits { + /// Maximum source bytes before compilation. + pub source_bytes: usize, + /// Maximum VM instructions. + pub instruction_budget: u64, + /// Maximum charged wall-clock milliseconds. + pub wall_budget_ms: u64, + /// Maximum accounted VM heap bytes. + pub memory_limit: usize, + /// Maximum table nesting depth. + pub max_depth: usize, + /// Maximum entries in one table. + pub max_keys: usize, + /// Maximum scalar/table values in the returned tree. + pub max_values: usize, + /// Maximum bytes in one string. + pub max_string_bytes: usize, + /// Maximum aggregate output bytes, including bounded structural overhead. + pub max_aggregate_bytes: usize, +} + +impl Default for PlainConfigLimits { + fn default() -> Self { + Self { + source_bytes: 64 * 1024, + instruction_budget: crate::RC1_INSTRUCTION_BUDGET, + wall_budget_ms: crate::RC1_WALL_CLOCK_BUDGET_MS, + memory_limit: crate::RC2_MEMORY_PER_PLUGIN_BYTES, + max_depth: 16, + max_keys: 64, + max_values: 256, + max_string_bytes: 2048, + max_aggregate_bytes: 16 * 1024, + } + } +} + +impl PlainConfigLimits { + fn validate(self) -> Result<(), VmError> { + if self.source_bytes == 0 + || self.instruction_budget == 0 + || self.wall_budget_ms == 0 + || self.memory_limit == 0 + || self.max_depth == 0 + || self.max_keys == 0 + || self.max_values == 0 + || self.max_string_bytes == 0 + || self.max_aggregate_bytes == 0 + { + return Err(VmError::Budget( + "plain configuration limits must be non-zero".to_string(), + )); + } + Ok(()) + } +} + +/// Failure of source loading, VM execution, or plain-data extraction. +#[derive(Debug, Clone, PartialEq)] +pub enum PlainConfigError { + /// Source exceeded the configured pre-compile cap. + SourceTooLarge, + /// VM construction or budget failure. + Vm(VmError), + /// Lua compile/runtime failure. + Lua(String), + /// Returned value has an invalid shape or type. + Shape(String), + /// Returned data exceeded an extraction bound. + Limit(String), +} + +impl std::fmt::Display for PlainConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SourceTooLarge => write!(f, "configuration source exceeds the byte limit"), + Self::Vm(error) => write!(f, "{error}"), + Self::Lua(message) | Self::Shape(message) | Self::Limit(message) => { + write!(f, "{message}") + } + } + } +} + +impl std::error::Error for PlainConfigError {} + +/// Fresh restricted VM used only for generic plugin configuration. +pub struct ConfigLuaVm { + vm: LuaVm, + limits: PlainConfigLimits, +} + +impl ConfigLuaVm { + /// Construct a fresh configuration VM. It has no host bridge state, module + /// root, plugin services, or retained side-effecting standard library. + pub fn with_limits(id: impl Into, limits: PlainConfigLimits) -> Result { + limits.validate()?; + Ok(Self { + vm: LuaVm::with_config_budgets( + id, + limits.instruction_budget, + limits.wall_budget_ms, + 0, + limits.memory_limit, + ), + limits, + }) + } + + /// Evaluate source and return an owned string-keyed table or dense array. + pub fn eval_plain_config( + &mut self, + source: &str, + ) -> Result { + if source.len() > self.limits.source_bytes { + return Err(PlainConfigError::SourceTooLarge); + } + reject_duplicate_literal_keys(source)?; + match self.vm.drive_chunk(source).map_err(PlainConfigError::Vm)? { + DriveOutcome::Suspended { reason, .. } => { + Err(PlainConfigError::Vm(VmError::Suspended { reason })) + } + DriveOutcome::Failed { message } => Err(PlainConfigError::Lua(message)), + DriveOutcome::Ready { stashed } => self.vm.take_plain_result(stashed, self.limits), + } + } +} + +fn reject_duplicate_literal_keys(source: &str) -> Result<(), PlainConfigError> { + let chunk = parse_chunk(source.as_bytes(), BasicInterner::default()) + .map_err(|error| PlainConfigError::Lua(format!("{error}")))?; + check_block(&chunk.block) +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum LiteralKey { + Integer(i64), + String(Vec), +} + +fn check_block(block: &Block) -> Result<(), PlainConfigError> +where + S: AsRef<[u8]> + Clone, +{ + for statement in &block.statements { + check_statement(&statement.inner)?; + } + if let Some(return_statement) = &block.return_statement { + for expression in &return_statement.returns { + check_expression(expression)?; + } + } + Ok(()) +} + +fn check_statement(statement: &Statement) -> Result<(), PlainConfigError> +where + S: AsRef<[u8]> + Clone, +{ + match statement { + Statement::If(if_statement) => { + check_expression(&if_statement.if_part.0)?; + check_block(&if_statement.if_part.1)?; + for (condition, block) in &if_statement.else_if_parts { + check_expression(condition)?; + check_block(block)?; + } + if let Some(block) = &if_statement.else_part { + check_block(block)?; + } + } + Statement::While(while_statement) => { + check_expression(&while_statement.condition)?; + check_block(&while_statement.block)?; + } + Statement::Do(block) => check_block(block)?, + Statement::For(for_statement) => match for_statement { + ForStatement::Numeric { + initial, + limit, + step, + body, + .. + } => { + check_expression(initial)?; + check_expression(limit)?; + if let Some(step) = step { + check_expression(step)?; + } + check_block(body)?; + } + ForStatement::Generic { + arguments, body, .. + } => { + for argument in arguments { + check_expression(argument)?; + } + check_block(body)?; + } + }, + Statement::Repeat(repeat_statement) => { + check_block(&repeat_statement.body)?; + check_expression(&repeat_statement.until)?; + } + Statement::Function(function_statement) => check_function(&function_statement.definition)?, + Statement::LocalFunction(function_statement) => { + check_function(&function_statement.definition)? + } + Statement::LocalStatement(local_statement) => { + for expression in &local_statement.values { + check_expression(expression)?; + } + } + Statement::FunctionCall(function_call) => { + check_suffixed_expression(&function_call.head)?; + check_call(&function_call.call)?; + } + Statement::Assignment(assignment) => { + for target in &assignment.targets { + match target { + AssignmentTarget::Name(_) => {} + AssignmentTarget::Field(expression, field) => { + check_suffixed_expression(expression)?; + check_field(field)?; + } + } + } + for expression in &assignment.values { + check_expression(expression)?; + } + } + Statement::Label(_) | Statement::Break | Statement::Goto(_) => {} + } + Ok(()) +} + +fn check_function( + function: &phodopus::compiler::parser::FunctionDefinition, +) -> Result<(), PlainConfigError> +where + S: AsRef<[u8]> + Clone, +{ + check_block(&function.body) +} + +fn check_expression(expression: &Expression) -> Result<(), PlainConfigError> +where + S: AsRef<[u8]> + Clone, +{ + check_head(&expression.head)?; + for (_, expression) in &expression.tail { + check_expression(expression)?; + } + Ok(()) +} + +fn check_head(head: &HeadExpression) -> Result<(), PlainConfigError> +where + S: AsRef<[u8]> + Clone, +{ + match head { + HeadExpression::Simple(simple) => match simple { + SimpleExpression::TableConstructor(table) => check_table(table)?, + SimpleExpression::Function(function) => check_function(function)?, + SimpleExpression::Suffixed(expression) => check_suffixed_expression(expression)?, + SimpleExpression::Float(_) + | SimpleExpression::Integer(_) + | SimpleExpression::String(_) + | SimpleExpression::Nil + | SimpleExpression::True + | SimpleExpression::False + | SimpleExpression::VarArgs => {} + }, + HeadExpression::UnaryOperator(_, expression) => check_expression(expression)?, + } + Ok(()) +} + +fn check_suffixed_expression(expression: &SuffixedExpression) -> Result<(), PlainConfigError> +where + S: AsRef<[u8]> + Clone, +{ + match &expression.primary { + PrimaryExpression::Name(_) => {} + PrimaryExpression::GroupedExpression(expression) => check_expression(expression)?, + } + for suffix in &expression.suffixes { + match suffix { + phodopus::compiler::parser::SuffixPart::Field(field) => check_field(field)?, + phodopus::compiler::parser::SuffixPart::Call(call) => check_call(call)?, + } + } + Ok(()) +} + +fn check_field(field: &FieldSuffix) -> Result<(), PlainConfigError> +where + S: AsRef<[u8]> + Clone, +{ + if let FieldSuffix::Indexed(expression) = field { + check_expression(expression)?; + } + Ok(()) +} + +fn check_call(call: &phodopus::compiler::parser::CallSuffix) -> Result<(), PlainConfigError> +where + S: AsRef<[u8]> + Clone, +{ + let arguments = match call { + phodopus::compiler::parser::CallSuffix::Method(_, arguments) + | phodopus::compiler::parser::CallSuffix::Function(arguments) => arguments, + }; + for argument in arguments { + check_expression(argument)?; + } + Ok(()) +} + +fn check_table(table: &TableConstructor) -> Result<(), PlainConfigError> +where + S: AsRef<[u8]> + Clone, +{ + let mut keys = BTreeSet::new(); + let mut next_array_index = 1_i64; + for field in &table.fields { + match field { + ConstructorField::Array(expression) => { + if !keys.insert(LiteralKey::Integer(next_array_index)) { + return Err(PlainConfigError::Shape( + "duplicate configuration key".to_string(), + )); + } + next_array_index = next_array_index.checked_add(1).ok_or_else(|| { + PlainConfigError::Limit("configuration array index overflow".to_string()) + })?; + check_expression(expression)?; + } + ConstructorField::Record(key, value) => { + let key = literal_key(key).ok_or_else(|| { + PlainConfigError::Shape( + "configuration keys must be literal strings or integer-compatible numbers" + .to_string(), + ) + })?; + if !keys.insert(key) { + return Err(PlainConfigError::Shape( + "duplicate configuration key".to_string(), + )); + } + check_expression(value)?; + } + } + } + Ok(()) +} + +fn literal_key(key: &RecordKey) -> Option +where + S: AsRef<[u8]> + Clone, +{ + match key { + RecordKey::Named(name) => Some(LiteralKey::String(name.as_ref().to_vec())), + RecordKey::Indexed(expression) => literal_expression(expression), + } +} + +fn literal_expression(expression: &Expression) -> Option +where + S: AsRef<[u8]> + Clone, +{ + if !expression.tail.is_empty() { + return None; + } + match expression.head.as_ref() { + HeadExpression::Simple(SimpleExpression::Integer(value)) => { + Some(LiteralKey::Integer(*value)) + } + HeadExpression::Simple(SimpleExpression::Float(value)) => finite_integral_float_key(*value), + HeadExpression::Simple(SimpleExpression::String(value)) => { + Some(LiteralKey::String(value.as_ref().to_vec())) + } + HeadExpression::Simple(SimpleExpression::Suffixed(expression)) + if expression.suffixes.is_empty() => + { + match &expression.primary { + PrimaryExpression::GroupedExpression(expression) => literal_expression(expression), + PrimaryExpression::Name(_) => None, + } + } + HeadExpression::UnaryOperator(operator, expression) => match operator { + phodopus::compiler::parser::UnaryOperator::Minus => { + match literal_expression(expression) { + Some(LiteralKey::Integer(value)) => { + Some(LiteralKey::Integer(value.wrapping_neg())) + } + _ => None, + } + } + phodopus::compiler::parser::UnaryOperator::BitNot => { + match literal_expression(expression) { + Some(LiteralKey::Integer(value)) => Some(LiteralKey::Integer(!value)), + _ => None, + } + } + phodopus::compiler::parser::UnaryOperator::Not + | phodopus::compiler::parser::UnaryOperator::Len => None, + }, + _ => None, + } +} + +/// Lua uses the same table key identity for an integer and an exactly integral +/// number. Keep the AST duplicate check bounded to identities representable by +/// the VM's `i64` integer values. Unsupported indexed expressions are rejected +/// before runtime evaluation because Lua may normalize them to a supported key. +fn finite_integral_float_key(value: f64) -> Option { + if !value.is_finite() || value.fract() != 0.0 { + return None; + } + + // `as i64` saturates outside the integer range, so check the range before + // the cast rather than allowing 2^63 to alias i64::MAX. + let min = i64::MIN as f64; + let max_exclusive = -(i64::MIN as f64); + if !(min..max_exclusive).contains(&value) { + return None; + } + let integer = value as i64; + (integer as f64 == value).then_some(LiteralKey::Integer(integer)) +} + +pub(crate) fn capture_plain_value<'gc>( + ctx: Context<'gc>, + value: Value<'gc>, + limits: PlainConfigLimits, +) -> Result { + let mut state = CaptureState { + limits, + values: 0, + aggregate_bytes: 0, + active_tables: Vec::new(), + }; + state.capture(ctx, value, 0) +} + +struct CaptureState<'gc> { + limits: PlainConfigLimits, + values: usize, + aggregate_bytes: usize, + active_tables: Vec>, +} + +impl<'gc> CaptureState<'gc> { + fn charge(&mut self, bytes: usize) -> Result<(), PlainConfigError> { + self.aggregate_bytes = self + .aggregate_bytes + .checked_add(bytes) + .ok_or_else(|| PlainConfigError::Limit("aggregate output overflow".to_string()))?; + if self.aggregate_bytes > self.limits.max_aggregate_bytes { + return Err(PlainConfigError::Limit( + "aggregate output exceeds limit".to_string(), + )); + } + Ok(()) + } + + fn capture( + &mut self, + ctx: Context<'gc>, + value: Value<'gc>, + depth: usize, + ) -> Result { + if depth > self.limits.max_depth { + return Err(PlainConfigError::Limit( + "configuration nesting exceeds depth limit".to_string(), + )); + } + if self.values >= self.limits.max_values { + return Err(PlainConfigError::Limit( + "configuration value count exceeds limit".to_string(), + )); + } + self.values = self.values.checked_add(1).ok_or_else(|| { + PlainConfigError::Limit("configuration value count overflow".to_string()) + })?; + match value { + Value::Boolean(value) => { + self.charge(1)?; + Ok(PlainConfigValue::Bool(value)) + } + Value::Integer(value) => { + self.charge(std::mem::size_of::())?; + Ok(PlainConfigValue::Integer(value)) + } + Value::Number(value) if value.is_finite() => { + self.charge(std::mem::size_of::())?; + Ok(PlainConfigValue::Number(value)) + } + Value::Number(_) => Err(PlainConfigError::Shape( + "non-finite numbers are not configuration data".to_string(), + )), + Value::String(value) => { + let bytes = value.as_bytes(); + if bytes.len() > self.limits.max_string_bytes { + return Err(PlainConfigError::Limit( + "configuration string exceeds limit".to_string(), + )); + } + self.charge(bytes.len())?; + let text = String::from_utf8(bytes.to_vec()).map_err(|_| { + PlainConfigError::Shape("configuration strings must be UTF-8".to_string()) + })?; + Ok(PlainConfigValue::String(text)) + } + Value::Table(table) => self.capture_table(ctx, table, depth), + other => Err(PlainConfigError::Shape(format!( + "unsupported configuration value type '{}'", + other.type_name() + ))), + } + } + + fn capture_table( + &mut self, + ctx: Context<'gc>, + table: Table<'gc>, + depth: usize, + ) -> Result { + if self.active_tables.contains(&table) { + return Err(PlainConfigError::Shape( + "cyclic configuration table".to_string(), + )); + } + if table.metatable().is_some() { + return Err(PlainConfigError::Shape( + "configuration tables may not have metatables".to_string(), + )); + } + self.charge(1)?; + self.active_tables.push(table); + let result = self.capture_table_pairs(ctx, table, depth); + self.active_tables.pop(); + result + } + + fn capture_table_pairs( + &mut self, + ctx: Context<'gc>, + table: Table<'gc>, + depth: usize, + ) -> Result { + let mut kind = None; + let mut table_keys = 0; + let mut arrays = BTreeMap::new(); + let mut maps = BTreeMap::new(); + for (key, value) in table.iter() { + if table_keys >= self.limits.max_keys { + return Err(PlainConfigError::Limit( + "configuration table key count exceeds limit".to_string(), + )); + } + table_keys = table_keys.checked_add(1).ok_or_else(|| { + PlainConfigError::Limit("configuration key count overflow".to_string()) + })?; + self.charge(2)?; + + let is_integer = matches!(key, Value::Integer(_)); + let is_string = matches!(key, Value::String(_)); + if !is_integer && !is_string { + return Err(PlainConfigError::Shape( + "configuration tables may not mix array and map keys".to_string(), + )); + } + if kind.is_some_and(|was_integer| was_integer != is_integer) { + return Err(PlainConfigError::Shape( + "configuration tables may not mix array and map keys".to_string(), + )); + } + kind = Some(is_integer); + + if is_integer { + let Value::Integer(index) = key else { + unreachable!() + }; + let Some(slot) = index.checked_sub(1).and_then(|i| usize::try_from(i).ok()) else { + return Err(PlainConfigError::Shape( + "configuration arrays must use contiguous positive integer keys" + .to_string(), + )); + }; + if slot >= self.limits.max_keys { + return Err(PlainConfigError::Shape( + "configuration arrays must use contiguous positive integer keys" + .to_string(), + )); + } + if arrays.contains_key(&slot) { + return Err(PlainConfigError::Shape( + "duplicate configuration array key".to_string(), + )); + } + arrays.insert(slot, self.capture(ctx, value, depth + 1)?); + } else { + let Value::String(key) = key else { + unreachable!() + }; + let bytes = key.as_bytes(); + if bytes.len() > self.limits.max_string_bytes { + return Err(PlainConfigError::Limit( + "configuration key exceeds string limit".to_string(), + )); + } + self.charge(bytes.len())?; + let key = String::from_utf8(bytes.to_vec()).map_err(|_| { + PlainConfigError::Shape("configuration keys must be UTF-8".to_string()) + })?; + if maps.contains_key(&key) { + return Err(PlainConfigError::Shape( + "duplicate configuration map key".to_string(), + )); + } + maps.insert(key, self.capture(ctx, value, depth + 1)?); + } + } + if kind == Some(true) { + let expected_len = arrays + .keys() + .next_back() + .and_then(|last| last.checked_add(1)) + .unwrap_or(0); + if arrays.len() != expected_len { + return Err(PlainConfigError::Shape( + "configuration arrays must be dense".to_string(), + )); + } + Ok(PlainConfigValue::Array(arrays.into_values().collect())) + } else { + Ok(PlainConfigValue::Table(maps)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn eval(source: &str) -> Result { + let mut vm = ConfigLuaVm::with_limits("test.config", PlainConfigLimits::default()) + .expect("valid limits"); + vm.eval_plain_config(source) + } + + #[test] + fn extracts_owned_plain_data() { + assert_eq!( + eval("return { enabled = true, count = 4, tags = {'a', 'b'} }").expect("plain data"), + PlainConfigValue::Table(BTreeMap::from([ + ("count".to_string(), PlainConfigValue::Integer(4)), + ("enabled".to_string(), PlainConfigValue::Bool(true)), + ( + "tags".to_string(), + PlainConfigValue::Array(vec![ + PlainConfigValue::String("a".to_string()), + PlainConfigValue::String("b".to_string()), + ]), + ), + ])) + ); + } + + #[test] + fn forbidden_globals_are_absent() { + for source in [ + "return { value = io.open }", + "return { value = os.time }", + "return { value = debug.getinfo }", + "return { value = package.loaded }", + "return { value = math.random() }", + "return { value = require('x') }", + "return { value = load('return 1') }", + "return { value = dofile('x') }", + ] { + assert!( + eval(source).is_err(), + "source unexpectedly succeeded: {source}" + ); + } + } + + #[test] + fn rejects_shapes_cycles_and_non_finite_numbers() { + for source in [ + "return 1", + "return { [1] = 'x', name = 'mixed' }", + "return { value = 0/0 }", + "local x = {}; x.self = x; return x", + "return setmetatable({}, {})", + "return { value = 1, value = 2 }", + r#"return { ["show"] = true, ["show"] = false }"#, + r#"return { ["show"] = true, ["sh\111w"] = false }"#, + ] { + assert!( + eval(source).is_err(), + "source unexpectedly succeeded: {source}" + ); + } + } + + #[test] + fn rejects_duplicate_integral_float_key_aliases() { + for source in [ + "return { [1] = true, [1.0] = false }", + "return { [1] = true, [1e0] = false }", + "return { [1] = true, [0x1p0] = false }", + "return { [0] = true, [-0.0] = false }", + "return { [-1] = true, [-1.0] = false }", + "return { [1000] = true, [1e3] = false }", + "return { [1] = true, [1.0 + 0.0] = false }", + "return { [1] = true, [+1.0] = false }", + ] { + assert!( + eval(source).is_err(), + "numeric key alias unexpectedly succeeded: {source}" + ); + } + + assert_eq!( + eval("return { value = 1.5 }").expect("nonintegral number value is valid"), + PlainConfigValue::Table(BTreeMap::from([( + "value".to_string(), + PlainConfigValue::Number(1.5), + )])) + ); + assert!(finite_integral_float_key(1.5).is_none()); + } + + #[test] + fn rejects_computed_indexed_keys_during_ast_validation() { + for source in [ + "return { [1.0 + 0.0] = true }", + "return { [1 + 0] = true }", + "return { [1 << 0] = true }", + "local key = 1; return { [key] = true }", + ] { + assert!( + reject_duplicate_literal_keys(source).is_err(), + "computed indexed key unexpectedly passed AST validation: {source}" + ); + } + } + + #[test] + fn preserves_string_and_unary_numeric_key_forms() { + for source in [ + r#"return { ["show"] = true }"#, + "return { [1] = true }", + "return { [1.0] = true }", + "return { [-1] = true }", + "return { [-1.0] = true }", + "return { [~1] = true }", + "return { [~1.0] = true }", + "return { [-~1] = true }", + ] { + assert!( + reject_duplicate_literal_keys(source).is_ok(), + "supported key form unexpectedly failed AST validation: {source}" + ); + } + } + + #[test] + fn rejects_unsupported_numeric_index_expressions() { + for source in ["return { [1.0 + 0.0] = true }", "return { [+1.0] = true }"] { + assert!( + eval(source).is_err(), + "unsupported numeric index unexpectedly succeeded: {source}" + ); + } + } + + #[test] + fn syntax_aware_duplicate_check_ignores_nested_code_and_strings() { + assert_eq!( + eval( + r#" + local make = function() + local nested = { value = true } + return { show = nested.value } + end + return make() + "#, + ) + .expect("nested function is valid configuration code"), + PlainConfigValue::Table(BTreeMap::from([( + "show".to_string(), + PlainConfigValue::Bool(true), + )])) + ); + assert_eq!( + eval( + r#" + return { + show = [=[text containing braces: { } and quotes: " ']=] + } + "#, + ) + .expect("long-bracket strings are valid configuration data"), + PlainConfigValue::Table(BTreeMap::from([( + "show".to_string(), + PlainConfigValue::String( + "text containing braces: { } and quotes: \" '".to_string(), + ), + )])) + ); + assert_eq!( + eval(r#"return { show = "line\nwith \"quotes\"" }"#) + .expect("escaped strings are valid configuration data"), + PlainConfigValue::Table(BTreeMap::from([( + "show".to_string(), + PlainConfigValue::String("line\nwith \"quotes\"".to_string()), + )])) + ); + assert!(eval("return { show = [=[unterminated } ").is_err()); + } + + #[test] + fn applies_source_and_value_bounds() { + let limits = PlainConfigLimits { + source_bytes: 4, + ..PlainConfigLimits::default() + }; + let mut vm = ConfigLuaVm::with_limits("test.config", limits).expect("valid limits"); + assert!(matches!( + vm.eval_plain_config("return {}"), + Err(PlainConfigError::SourceTooLarge) + )); + let limits = PlainConfigLimits { + max_values: 2, + ..PlainConfigLimits::default() + }; + let mut vm = ConfigLuaVm::with_limits("test.config", limits).expect("valid limits"); + assert!(matches!( + vm.eval_plain_config("return {a = {b = true}}"), + Err(PlainConfigError::Limit(_)) + )); + } + + #[test] + fn rejects_excessive_table_keys_incrementally() { + let limits = PlainConfigLimits { + max_keys: 2, + ..PlainConfigLimits::default() + }; + let mut vm = ConfigLuaVm::with_limits("test.config", limits).expect("valid limits"); + assert!(matches!( + vm.eval_plain_config( + "local t = {}; for i = 1, 1000 do t[i] = string.rep('x', 1024) end; return t" + ), + Err(PlainConfigError::Limit(_)) + )); + } + + #[test] + fn applies_key_bound_per_table() { + let limits = PlainConfigLimits { + max_keys: 1, + ..PlainConfigLimits::default() + }; + let mut vm = ConfigLuaVm::with_limits("test.config", limits).expect("valid limits"); + assert!( + vm.eval_plain_config("return { outer = { inner = true } }") + .is_ok() + ); + } +} diff --git a/crates/bitty-lua/src/stdlib.rs b/crates/bitty-lua/src/stdlib.rs index 0678536fd..b5b019a3a 100644 --- a/crates/bitty-lua/src/stdlib.rs +++ b/crates/bitty-lua/src/stdlib.rs @@ -30,6 +30,39 @@ pub(crate) fn install_retained_stdlib(lua: &mut Lua) { install_table_sort(lua); } +/// Install only pure computation libraries for the dedicated configuration VM. +/// The base loader itself includes dynamic loading and debug helpers, so the +/// configuration surface is scrubbed after loading and before untrusted code +/// runs. No retained `os` helpers or host callbacks are installed here. +pub(crate) fn install_config_stdlib(lua: &mut Lua) { + lua.enter(|ctx| { + phodopus::stdlib::load_base(ctx); + phodopus::stdlib::load_string(ctx); + phodopus::stdlib::load_table(ctx); + phodopus::stdlib::load_math(ctx); + phodopus::stdlib::load_utf8(ctx); + if let Ok(math) = ctx.get_global::("math") { + math.set_field(ctx, "random", Value::Nil); + math.set_field(ctx, "randomseed", Value::Nil); + } + for name in [ + "_G", + "debug", + "package", + "require", + "load", + "loadfile", + "dofile", + "io", + "os", + "coroutine", + "tonumber", + ] { + ctx.set_global(name, phodopus::Value::Nil); + } + }); +} + /// Restricted `os` retained by the accepted baseline: `time`/`clock`/`date` /// only. `os.execute`, `os.getenv`, `os.remove`, `os.tmpname`, and the rest are /// deliberately absent (no process, environment, or filesystem authority). diff --git a/crates/bitty-runtime/Cargo.toml b/crates/bitty-runtime/Cargo.toml index 090e064ad..9855910eb 100644 --- a/crates/bitty-runtime/Cargo.toml +++ b/crates/bitty-runtime/Cargo.toml @@ -88,6 +88,9 @@ rustix = { workspace = true, features = ["fs", "std"] } [target.'cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd", target_os = "dragonfly"))'.dependencies] nix = { workspace = true, features = ["fs"] } +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] # Hermetic `ServeContext` constructors and unbound bearer minters (#1519). diff --git a/crates/bitty-runtime/src/plugin_runtime/mod.rs b/crates/bitty-runtime/src/plugin_runtime/mod.rs index 07df02bf4..5d348851a 100644 --- a/crates/bitty-runtime/src/plugin_runtime/mod.rs +++ b/crates/bitty-runtime/src/plugin_runtime/mod.rs @@ -552,6 +552,8 @@ struct PluginEntry { registrations: RegistrationCapture, } +type DiscoveryResults = Vec<(PluginId, Result<(), PluginRuntimeError>)>; + /// Orchestrates discovery, activation, and dispatch for plugin VMs. /// /// The struct is `!Send` because it owns Phodopus VMs; callers use it on the @@ -573,6 +575,10 @@ pub struct PluginRuntime { /// /// [`settings`]: PluginRuntime::settings settings_dir: Option, + /// Complete discovery snapshot used for short-name settings aliases. + /// `None` means discovery was never completed or reported an error, so + /// aliases fail closed rather than being inferred from activated entries. + settings_alias_registry: Option>, snapshot: Rc, notifications: Rc>, bundled_roots: Vec, @@ -770,6 +776,7 @@ impl PluginRuntime { host: PluginHost::new(DropPolicy::DropOldest, crate::DEFAULT_PLUGIN_SIDE_CAPACITY), settings: config.settings, settings_dir: None, + settings_alias_registry: None, snapshot: config.snapshot, notifications: Rc::new(RefCell::new(NotificationQueue::new( NOTIFICATION_QUEUE_CAPACITY, @@ -1630,16 +1637,34 @@ impl PluginRuntime { /// host never falls back to a different revision or to bundled content. /// Returns `(id, result)` pairs in discovery order. pub fn discover(&mut self) -> Vec<(PluginId, Result<(), PluginRuntimeError>)> { - let results = self.discover_inner(); + // A new pass invalidates any previous alias snapshot immediately. It + // is restored only after this pass proves that every configured source + // was available and every candidate was resolved successfully. + self.settings_alias_registry = None; + let (results, registry_ids) = self.discover_inner(); + let roots_available = self + .bundled_roots + .iter() + .chain(self.third_party_roots.iter()) + .all(|root| root.is_dir()); + let store_available = + self.safe_mode || self.store_root.as_ref().is_none_or(|root| root.is_dir()); + self.settings_alias_registry = (roots_available && store_available) + .then(|| Self::complete_discovery_ids(registry_ids, &results)) + .flatten(); self.sync_debug_view(); results } - fn discover_inner(&mut self) -> Vec<(PluginId, Result<(), PluginRuntimeError>)> { + fn discover_inner(&mut self) -> (DiscoveryResults, Vec) { let mut results = Vec::new(); + // This registry is intentionally independent of activation: disabled + // installed records still claim their short names and must participate + // in collision checks. + let mut registry_ids = Vec::new(); for root in self.bundled_roots.clone() { - self.collect_dir_root(&root, SourceClass::Bundled, &mut results); + self.collect_dir_root(&root, SourceClass::Bundled, &mut results, &mut registry_ids); } // RFC A.4 rule 6 / R-009: `--safe` never reads the third-party store @@ -1654,9 +1679,6 @@ impl PluginRuntime { match resolution::load_index(&store_root) { Ok(records) => { for record in records { - if !record.enabled { - continue; - } let Ok(id) = PluginId::new(&record.plugin_id) else { results.push(( invalid_plugin_id(), @@ -1667,6 +1689,13 @@ impl PluginRuntime { )); continue; }; + // A valid index record is a candidate even when its + // desired state is disabled. Do not resolve disabled + // packages merely to decide whether an alias exists. + registry_ids.push(id.as_str().to_string()); + if !record.enabled { + continue; + } if self.entries.contains_key(&id) { continue; } @@ -1688,10 +1717,31 @@ impl PluginRuntime { } for root in self.third_party_roots.clone() { - self.collect_dir_root(&root, SourceClass::LocalPath, &mut results); + self.collect_dir_root( + &root, + SourceClass::LocalPath, + &mut results, + &mut registry_ids, + ); } - results + (results, registry_ids) + } + + /// Build the alias registry from one complete discovery pass. + /// + /// Failed or unavailable candidates remain in `results` as error rows, so + /// a short name is never declared unique from the successful subset. + fn complete_discovery_ids( + mut registry_ids: Vec, + results: &DiscoveryResults, + ) -> Option> { + if results.iter().any(|(_, result)| result.is_err()) { + return None; + } + registry_ids.sort(); + registry_ids.dedup(); + Some(registry_ids) } fn collect_dir_root( @@ -1699,6 +1749,7 @@ impl PluginRuntime { root: &Path, source_class: SourceClass, results: &mut Vec<(PluginId, Result<(), PluginRuntimeError>)>, + registry_ids: &mut Vec, ) { let packages = match discover_root(root, source_class) { Ok(packages) => packages, @@ -1709,6 +1760,7 @@ impl PluginRuntime { }; for package in packages { let id = package.manifest.id().clone(); + registry_ids.push(id.as_str().to_string()); if self.entries.contains_key(&id) { continue; } @@ -1937,11 +1989,12 @@ impl PluginRuntime { // fails closed structurally and at the gate. A malformed file // resolves to lower layers (never aborts activation); grants are // untouched by construction (this path names no capability). - let loaded = plugin_settings::load_plugin_settings( + let loaded = plugin_settings::load_plugin_settings_with_plugins( self.settings_dir.as_deref(), id.as_str(), &manifest.settings, Some(&*self.settings), + self.settings_alias_registry.as_deref(), ); let services = Rc::new(PluginServices::new( id.as_str(), @@ -3767,6 +3820,7 @@ impl PluginRuntime { pub fn set_roots(&mut self, bundled_roots: Vec, third_party_roots: Vec) { self.bundled_roots = bundled_roots; self.third_party_roots = third_party_roots; + self.settings_alias_registry = None; } /// Trusted (`bundled`) discovery roots. @@ -3784,6 +3838,7 @@ impl PluginRuntime { /// Replace the resolved plugin store root after construction. pub fn set_store_root(&mut self, store_root: Option) { self.store_root = store_root; + self.settings_alias_registry = None; } /// Resolved plugin store root, if any. @@ -4130,4 +4185,35 @@ mod tests { other => panic!("expected LimitExceeded, got {other}"), } } + + #[test] + fn alias_registry_requires_complete_current_discovery() { + let first = PluginId::new("xuepoo.bar").expect("id"); + let second = PluginId::new("other.bar").expect("id"); + let complete = vec![(first.clone(), Ok(())), (second, Ok(()))]; + assert_eq!( + PluginRuntime::complete_discovery_ids( + vec!["xuepoo.bar".to_string(), "other.bar".to_string()], + &complete, + ), + Some(vec!["other.bar".to_string(), "xuepoo.bar".to_string()]) + ); + + let incomplete = vec![ + (first, Ok(())), + ( + invalid_plugin_id(), + Err(PluginRuntimeError::Io("index unavailable".to_string())), + ), + ]; + assert_eq!( + PluginRuntime::complete_discovery_ids(vec!["xuepoo.bar".to_string()], &incomplete), + None + ); + let empty: DiscoveryResults = Vec::new(); + assert_eq!( + PluginRuntime::complete_discovery_ids(Vec::new(), &empty), + Some(Vec::new()) + ); + } } diff --git a/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs b/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs index 2418c8ee9..e84ead198 100644 --- a/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs +++ b/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs @@ -46,9 +46,11 @@ //! [`SettingsSource`]: super::services::SettingsSource use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; use std::path::Path; use bitty_config::plugin_file::{self, ParsedPluginFile, PluginFileValue}; +use bitty_lua::{ConfigLuaVm, PlainConfigLimits, PlainConfigValue}; use bitty_plugin_host::manifest::{SettingDecl, SettingDefault, SettingKind}; use super::LuaValue; @@ -263,6 +265,299 @@ pub struct LoadedPluginSettings { pub file_error: Option, } +fn plain_value_to_lua(value: &PlainConfigValue) -> Option { + match value { + PlainConfigValue::Bool(value) => Some(LuaValue::Bool(*value)), + PlainConfigValue::Integer(value) => Some(LuaValue::Integer(*value)), + PlainConfigValue::String(value) => Some(LuaValue::String(value.clone())), + PlainConfigValue::Array(values) + if values + .iter() + .all(|value| matches!(value, PlainConfigValue::String(_))) => + { + Some(LuaValue::array( + values + .iter() + .map(|value| match value { + PlainConfigValue::String(value) => LuaValue::String(value.clone()), + _ => unreachable!(), + }) + .collect(), + )) + } + PlainConfigValue::Array(_) => None, + PlainConfigValue::Table(values) if values.is_empty() => Some(LuaValue::array(Vec::new())), + PlainConfigValue::Number(_) | PlainConfigValue::Table(_) => None, + } +} + +fn plain_settings_to_lua( + value: PlainConfigValue, +) -> Result<(BTreeMap, Vec), String> { + let PlainConfigValue::Table(values) = value else { + return Err("plugin configuration must return a table".to_string()); + }; + let mut output = BTreeMap::new(); + let mut dropped = Vec::new(); + for (key, value) in values { + if let Some(value) = plain_value_to_lua(&value) { + output.insert(key, value); + } else { + dropped.push(key); + } + } + Ok((output, dropped)) +} + +fn merge_lua_settings( + declared: &[SettingDecl], + lua: Option<&BTreeMap>, + base: Option<&dyn SettingsSource>, +) -> ResolvedPluginSettings { + let mut values = BTreeMap::new(); + let mut dropped = BTreeSet::new(); + for decl in declared { + let mut value = default_to_lua(&decl.default); + if let Some(source) = base { + if let Some(candidate) = source.get(&decl.key) { + if lua_value_matches_kind(&candidate, decl.kind) { + value = candidate; + } + } + } + if let Some(source) = lua { + match source.get(&decl.key) { + Some(candidate) if lua_value_matches_kind(candidate, decl.kind) => { + value = candidate.clone(); + } + Some(_) => { + dropped.insert(decl.key.clone()); + } + None => {} + } + } + values.insert(decl.key.clone(), value); + } + if let Some(source) = lua { + for key in source.keys() { + if !values.contains_key(key) { + dropped.insert(key.clone()); + } + } + } + ResolvedPluginSettings { + values, + dropped: dropped.into_iter().collect(), + } +} + +fn lua_config_paths( + config_dir: &Path, + plugin_id: &str, + discovered_ids: Option<&[String]>, +) -> Option<(std::path::PathBuf, Option)> { + if !plugin_file::valid_plugin_file_id(plugin_id) { + return None; + } + let plugins = config_dir.join(plugin_file::PLUGINS_DIR_NAME); + let canonical = plugins.join(format!("{plugin_id}.lua")); + let alias = discovered_ids.and_then(|ids| { + if ids.iter().any(|id| !plugin_file::valid_plugin_file_id(id)) { + return None; + } + if !ids.iter().any(|id| id == plugin_id) { + return None; + } + let (_, short_name) = plugin_id.split_once('.')?; + let owners: BTreeSet<&str> = ids + .iter() + .filter_map(|id| { + let (owner, name) = id.split_once('.')?; + (name == short_name && plugin_file::valid_plugin_file_id(id)).then_some(owner) + }) + .collect(); + (owners.len() == 1).then(|| plugins.join(format!("{short_name}.lua"))) + }); + Some((canonical, alias)) +} + +#[derive(Debug, PartialEq, Eq)] +enum ConfigReadError { + NotFound, + Rejected(String), +} + +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" +))] +fn read_bounded_regular_file(path: &Path) -> Result, ConfigReadError> { + #[cfg(any(target_os = "linux", target_os = "android"))] + let file = { + #[cfg(any(target_os = "linux", target_os = "android"))] + let result = rustix::fs::open( + path, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::NONBLOCK + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ); + match result { + Ok(fd) => std::fs::File::from(fd), + Err(error) if error == rustix::io::Errno::NOENT => { + return Err(ConfigReadError::NotFound); + } + Err(error) => { + return Err(ConfigReadError::Rejected(format!( + "unreadable plugin Lua config: {error}" + ))); + } + } + }; + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" + ))] + let file = { + let result = nix::fcntl::open( + path, + nix::fcntl::OFlag::O_RDONLY + | nix::fcntl::OFlag::O_NOFOLLOW + | nix::fcntl::OFlag::O_NONBLOCK + | nix::fcntl::OFlag::O_CLOEXEC, + nix::sys::stat::Mode::empty(), + ); + match result { + Ok(fd) => std::fs::File::from(fd), + Err(error) if error == nix::errno::Errno::ENOENT => { + return Err(ConfigReadError::NotFound); + } + Err(error) => { + return Err(ConfigReadError::Rejected(format!( + "unreadable plugin Lua config: {error}" + ))); + } + } + }; + + #[cfg(all( + unix, + not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" + )) + ))] + return Err(ConfigReadError::Rejected( + "plugin Lua config loading is unsupported on this platform".to_string(), + )); + + let metadata = file.metadata().map_err(|error| { + ConfigReadError::Rejected(format!("unreadable plugin Lua config: {error}")) + })?; + if !metadata.is_file() { + return Err(ConfigReadError::Rejected( + "plugin Lua config must be a regular file".to_string(), + )); + } + if metadata.len() > bitty_config::plugin_file::MAX_PLUGIN_FILE_BYTES as u64 { + return Err(ConfigReadError::Rejected( + "plugin Lua config exceeds the byte ceiling".to_string(), + )); + } + let mut bytes = Vec::new(); + file.take(bitty_config::plugin_file::MAX_PLUGIN_FILE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| { + ConfigReadError::Rejected(format!("unreadable plugin Lua config: {error}")) + })?; + if bytes.len() as u64 != metadata.len() + || bytes.len() > bitty_config::plugin_file::MAX_PLUGIN_FILE_BYTES + { + return Err(ConfigReadError::Rejected( + "plugin config changed while reading".to_string(), + )); + } + Ok(bytes) +} + +#[cfg(windows)] +fn read_bounded_regular_file(path: &Path) -> Result, ConfigReadError> { + use std::os::windows::fs::OpenOptionsExt; + + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + ConfigReadError::NotFound + } else { + ConfigReadError::Rejected(format!("unreadable plugin config: {error}")) + } + })?; + let metadata = file + .metadata() + .map_err(|error| ConfigReadError::Rejected(format!("unreadable plugin config: {error}")))?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(ConfigReadError::Rejected( + "plugin config must be a regular file".to_string(), + )); + } + if metadata.len() > bitty_config::plugin_file::MAX_PLUGIN_FILE_BYTES as u64 { + return Err(ConfigReadError::Rejected( + "plugin config exceeds the byte ceiling".to_string(), + )); + } + let mut bytes = Vec::new(); + file.take(bitty_config::plugin_file::MAX_PLUGIN_FILE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| ConfigReadError::Rejected(format!("unreadable plugin config: {error}")))?; + if bytes.len() as u64 != metadata.len() + || bytes.len() > bitty_config::plugin_file::MAX_PLUGIN_FILE_BYTES + { + return Err(ConfigReadError::Rejected( + "plugin config changed while reading".to_string(), + )); + } + Ok(bytes) +} + +#[cfg(all( + not(windows), + not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" + )) +))] +fn read_bounded_regular_file(_path: &Path) -> Result, ConfigReadError> { + Err(ConfigReadError::Rejected( + "plugin config loading is unsupported on this platform".to_string(), + )) +} + /// Load and resolve one plugin's settings (file I/O at the boundary). /// /// `config_dir` is the `/bitty` directory (env-derived by the @@ -279,18 +574,98 @@ pub fn load_plugin_settings( plugin_id: &str, declared: &[SettingDecl], base: Option<&dyn SettingsSource>, +) -> LoadedPluginSettings { + load_plugin_settings_with_plugins(config_dir, plugin_id, declared, base, None) +} + +/// Load settings with the complete discovered-plugin map used for short-name +/// alias admission. `None` deliberately disables aliases. +pub fn load_plugin_settings_with_plugins( + config_dir: Option<&Path>, + plugin_id: &str, + declared: &[SettingDecl], + base: Option<&dyn SettingsSource>, + discovered_ids: Option<&[String]>, ) -> LoadedPluginSettings { let path = match (config_dir, plugin_file::valid_plugin_file_id(plugin_id)) { // Validated ids match `owner.name` with no separators, so joining // the file name cannot escape the `plugins/` directory (fail-closed // `None` otherwise). + (Some(dir), true) => lua_config_paths(dir, plugin_id, discovered_ids), + _ => None, + }; + let lua_bytes = match path { + Some((canonical_path, alias_path)) => match read_bounded_regular_file(&canonical_path) { + Ok(bytes) => Some(bytes), + Err(ConfigReadError::NotFound) => match alias_path { + Some(alias_path) => match read_bounded_regular_file(&alias_path) { + Ok(bytes) => Some(bytes), + Err(ConfigReadError::NotFound) => None, + Err(ConfigReadError::Rejected(error)) => { + let resolved = merge_lua_settings(declared, None, base); + return LoadedPluginSettings { + source: PluginSettingsSource::new(resolved.values), + dropped: resolved.dropped, + file_error: Some(error), + }; + } + }, + None => None, + }, + Err(ConfigReadError::Rejected(error)) => { + let resolved = merge_lua_settings(declared, None, base); + return LoadedPluginSettings { + source: PluginSettingsSource::new(resolved.values), + dropped: resolved.dropped, + file_error: Some(error), + }; + } + }, + None => None, + }; + if let Some(bytes) = lua_bytes { + let limits = PlainConfigLimits { + source_bytes: bitty_config::plugin_file::MAX_PLUGIN_FILE_BYTES, + ..PlainConfigLimits::default() + }; + let lua_result = ConfigLuaVm::with_limits(plugin_id, limits) + .map_err(|error| error.to_string()) + .and_then(|mut vm| { + let source = std::str::from_utf8(&bytes).map_err(|error| error.to_string())?; + vm.eval_plain_config(source) + .map_err(|error| error.to_string()) + }); + match lua_result.and_then(plain_settings_to_lua) { + Ok((values, mut dropped)) => { + let resolved = merge_lua_settings(declared, Some(&values), base); + dropped.extend(resolved.dropped); + dropped.sort(); + dropped.dedup(); + return LoadedPluginSettings { + source: PluginSettingsSource::new(resolved.values), + dropped, + file_error: None, + }; + } + Err(error) => { + let resolved = merge_lua_settings(declared, None, base); + return LoadedPluginSettings { + source: PluginSettingsSource::new(resolved.values), + dropped: resolved.dropped, + file_error: Some(format!("plugin Lua config rejected: {error}")), + }; + } + } + } + + let toml_path = match (config_dir, plugin_file::valid_plugin_file_id(plugin_id)) { (Some(dir), true) => Some( dir.join(plugin_file::PLUGINS_DIR_NAME) .join(format!("{plugin_id}.toml")), ), _ => None, }; - let Some(path) = path else { + let Some(path) = toml_path else { let resolved = resolve_plugin_settings(declared, None, base); return LoadedPluginSettings { source: PluginSettingsSource::new(resolved.values), @@ -298,9 +673,9 @@ pub fn load_plugin_settings( file_error: None, }; }; - let bytes = match std::fs::read(&path) { + let bytes = match read_bounded_regular_file(&path) { Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Err(ConfigReadError::NotFound) => { let resolved = resolve_plugin_settings(declared, None, base); return LoadedPluginSettings { source: PluginSettingsSource::new(resolved.values), @@ -308,12 +683,12 @@ pub fn load_plugin_settings( file_error: None, }; } - Err(error) => { + Err(ConfigReadError::Rejected(error)) => { let resolved = resolve_plugin_settings(declared, None, base); return LoadedPluginSettings { source: PluginSettingsSource::new(resolved.values), dropped: resolved.dropped, - file_error: Some(format!("unreadable plugin config: {error}")), + file_error: Some(error), }; } }; @@ -603,4 +978,209 @@ mod tests { assert!(loaded.file_error.is_none()); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn lua_canonical_wins_over_toml_and_alias() { + let dir = std::env::temp_dir().join(format!( + "bitty-plugin-settings-lua-precedence-{}", + std::process::id() + )); + let plugins = dir.join("plugins"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&plugins).expect("temp dir"); + std::fs::write(plugins.join("xuepoo.bar.toml"), b"show = true\n").expect("toml"); + std::fs::write(plugins.join("bar.lua"), b"return { show = false }\n").expect("alias"); + std::fs::write( + plugins.join("xuepoo.bar.lua"), + b"return { show = true, edge = 'top' }\n", + ) + .expect("canonical"); + let ids = vec!["xuepoo.bar".to_string()]; + let loaded = load_plugin_settings_with_plugins( + Some(&dir), + "xuepoo.bar", + &bar_decls(), + None, + Some(&ids), + ); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert_eq!( + loaded.source.get("edge"), + Some(LuaValue::String("top".to_string())) + ); + assert!(loaded.file_error.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unique_alias_applies_and_collisions_disable_it() { + let dir = std::env::temp_dir().join(format!( + "bitty-plugin-settings-lua-alias-{}", + std::process::id() + )); + let plugins = dir.join("plugins"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&plugins).expect("temp dir"); + std::fs::write(plugins.join("bar.lua"), b"return { show = false }\n").expect("alias"); + let unique = vec!["xuepoo.bar".to_string()]; + let loaded = load_plugin_settings_with_plugins( + Some(&dir), + "xuepoo.bar", + &bar_decls(), + None, + Some(&unique), + ); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(false))); + let collision = vec!["xuepoo.bar".to_string(), "other.bar".to_string()]; + let loaded = load_plugin_settings_with_plugins( + Some(&dir), + "xuepoo.bar", + &bar_decls(), + None, + Some(&collision), + ); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert!(loaded.file_error.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn invalid_lua_does_not_fall_through_to_toml() { + let dir = std::env::temp_dir().join(format!( + "bitty-plugin-settings-lua-invalid-{}", + std::process::id() + )); + let plugins = dir.join("plugins"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&plugins).expect("temp dir"); + std::fs::write(plugins.join("xuepoo.bar.lua"), b"return io\n").expect("lua"); + std::fs::write(plugins.join("xuepoo.bar.toml"), b"show = false\n").expect("toml"); + let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert!(loaded.file_error.is_some()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn lua_declared_keys_and_types_are_strictly_filtered() { + let dir = std::env::temp_dir().join(format!( + "bitty-plugin-settings-lua-types-{}", + std::process::id() + )); + let plugins = dir.join("plugins"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&plugins).expect("temp dir"); + std::fs::write( + plugins.join("xuepoo.bar.lua"), + b"return { show = 'bad', edge = 'top', name_max_chars = 3.5, hidden = true }\n", + ) + .expect("lua"); + let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert_eq!( + loaded.source.get("edge"), + Some(LuaValue::String("top".to_string())) + ); + assert_eq!( + loaded.source.get("name_max_chars"), + Some(LuaValue::Integer(32)) + ); + assert!(loaded.dropped.iter().any(|key| key == "hidden")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unsafe_plugin_id_never_constructs_a_config_path() { + let dir = std::env::temp_dir().join(format!( + "bitty-plugin-settings-lua-path-{}", + std::process::id() + )); + let plugins = dir.join("plugins"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&plugins).expect("temp dir"); + std::fs::write(plugins.join("escape.lua"), b"return { show = false }\n").expect("lua"); + let loaded = load_plugin_settings(Some(&dir), "../escape", &bar_decls(), None); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert!(loaded.file_error.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn config_loader_rejects_symlink_directory_and_fifo() { + let dir = std::env::temp_dir().join(format!( + "bitty-plugin-settings-lua-file-types-{}", + std::process::id() + )); + let plugins = dir.join("plugins"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&plugins).expect("temp dir"); + + let target = plugins.join("target.lua"); + std::fs::write(&target, b"return { show = false }\n").expect("target"); + std::os::unix::fs::symlink(&target, plugins.join("xuepoo.bar.lua")).expect("symlink"); + let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert!(loaded.file_error.is_some()); + + std::fs::remove_file(plugins.join("xuepoo.bar.lua")).expect("remove symlink"); + std::fs::create_dir(plugins.join("xuepoo.bar.lua")).expect("directory"); + let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert!(loaded.file_error.is_some()); + + std::fs::remove_dir(plugins.join("xuepoo.bar.lua")).expect("remove directory"); + let fifo = plugins.join("xuepoo.bar.lua"); + rustix::fs::mkfifoat( + rustix::fs::CWD, + &fifo, + rustix::fs::Mode::from_raw_mode(0o600), + ) + .expect("fifo"); + let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert!(loaded.file_error.is_some()); + + std::fs::remove_file(plugins.join("xuepoo.bar.lua")).expect("remove fifo"); + std::os::unix::fs::symlink(&target, plugins.join("xuepoo.bar.toml")).expect("toml symlink"); + let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert!(loaded.file_error.is_some()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn aliases_fail_closed_without_a_complete_unique_registry() { + let dir = std::env::temp_dir().join(format!( + "bitty-plugin-settings-lua-alias-registry-{}", + std::process::id() + )); + let plugins = dir.join("plugins"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&plugins).expect("temp dir"); + std::fs::write(plugins.join("bar.lua"), b"return { show = false }\n").expect("alias"); + + for ids in [None, Some(&[][..]), Some(&["other.bar".to_string()][..])] { + let loaded = load_plugin_settings_with_plugins( + Some(&dir), + "xuepoo.bar", + &bar_decls(), + None, + ids, + ); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert!(loaded.file_error.is_none()); + } + let invalid = ["xuepoo.bar".to_string(), "bad/id".to_string()]; + let loaded = load_plugin_settings_with_plugins( + Some(&dir), + "xuepoo.bar", + &bar_decls(), + None, + Some(&invalid), + ); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/bitty-runtime/tests/plugin_settings_file.rs b/crates/bitty-runtime/tests/plugin_settings_file.rs index cbf3a684a..8adc67c49 100644 --- a/crates/bitty-runtime/tests/plugin_settings_file.rs +++ b/crates/bitty-runtime/tests/plugin_settings_file.rs @@ -24,6 +24,7 @@ use std::path::{Path, PathBuf}; use std::rc::Rc; use bitty_plugin_host::manifest::PluginId; +use bitty_runtime::plugin_runtime::package::{LocalInstallOptions, install_local_dir, set_enabled}; use bitty_runtime::plugin_runtime::{ EmptySettings, LifecycleState, LuaValue, PluginRuntime, PluginRuntimeConfig, SnapshotSource, UnavailableSnapshot, @@ -116,6 +117,21 @@ fn write_bar_plugin(root: &Path) { std::fs::write(plugin.join("lua/init.lua"), BAR_SRC).expect("init"); } +#[cfg(unix)] +fn write_invalid_plugin_candidate(root: &Path) { + let plugin = root.join("other.bar"); + std::fs::create_dir_all(&plugin).expect("candidate dir"); + std::fs::write(plugin.join("bitty-plugin.toml"), b"[plugin\n").expect("invalid manifest"); +} + +fn write_collision_plugin(root: &Path) { + let plugin = root.join("other.bar"); + std::fs::create_dir_all(plugin.join("lua")).expect("dirs"); + let manifest = BAR_MANIFEST.replace("bitty-terminal.bar", "other.bar"); + std::fs::write(plugin.join("bitty-plugin.toml"), manifest).expect("manifest"); + std::fs::write(plugin.join("lua/init.lua"), BAR_SRC).expect("init"); +} + fn write_user_file(config_dir: &Path, body: &[u8]) { let plugins = config_dir.join("plugins"); std::fs::create_dir_all(&plugins).expect("plugins dir"); @@ -213,3 +229,83 @@ fn malformed_file_keeps_plugin_alive_on_defaults_end_to_end() { "show=true;edge=bottom;show_single=false;name_max_chars=32;modules=workspaces" ); } + +#[cfg(unix)] +#[test] +fn short_alias_fails_closed_when_registry_has_error_candidate() { + let root = temp_dir("root-alias-error-candidate"); + write_bar_plugin(&root); + let error_root = temp_dir("root-alias-error-candidate-invalid"); + write_invalid_plugin_candidate(&error_root); + let config = temp_dir("config-alias-error-candidate"); + let plugins = config.join("plugins"); + std::fs::create_dir_all(&plugins).expect("plugins dir"); + std::fs::write(plugins.join("bar.lua"), b"return { show = false }\n").expect("alias"); + + let mut runtime = PluginRuntime::new(PluginRuntimeConfig { + safe_mode: false, + data_dir: Some(temp_dir("data-alias-error-candidate")), + store_root: None, + bundled_roots: Vec::new(), + third_party_roots: vec![root, error_root], + settings: Rc::new(EmptySettings), + snapshot: Rc::new(UnavailableSnapshot) as Rc, + }); + runtime.set_settings_dir(Some(config)); + let results = runtime.discover(); + assert!(results.iter().any(|(_, result)| result.is_err())); + let id = PluginId::new(BAR_ID).expect("id"); + runtime.activate(&id).expect("bar fixture must activate"); + assert_eq!( + read_settings(&mut runtime), + "show=true;edge=bottom;show_single=false;name_max_chars=32;modules=workspaces" + ); +} + +#[test] +fn short_alias_fails_closed_for_disabled_installed_collision() { + let root = temp_dir("root-disabled-installed-collision"); + write_bar_plugin(&root); + let source_root = temp_dir("source-disabled-installed-collision"); + let source = source_root.join("other.bar"); + write_collision_plugin(&source_root); + let store = temp_dir("store-disabled-installed-collision"); + install_local_dir( + &store, + &source, + &LocalInstallOptions::default(), + &mut |_| Ok(true), + ) + .expect("install") + .expect("approved"); + assert!(set_enabled(&store, "other.bar", false).expect("disable")); + + let config = temp_dir("config-disabled-installed-collision"); + let plugins = config.join("plugins"); + std::fs::create_dir_all(&plugins).expect("plugins dir"); + std::fs::write(plugins.join("bar.lua"), b"return { show = false }\n").expect("alias"); + + let mut runtime = PluginRuntime::new(PluginRuntimeConfig { + safe_mode: false, + data_dir: Some(temp_dir("data-disabled-installed-collision")), + store_root: Some(store), + bundled_roots: Vec::new(), + third_party_roots: vec![root], + settings: Rc::new(EmptySettings), + snapshot: Rc::new(UnavailableSnapshot) as Rc, + }); + runtime.set_settings_dir(Some(config)); + let results = runtime.discover(); + assert_eq!( + results.len(), + 1, + "disabled candidate must not activate: {results:?}" + ); + assert!(results.iter().all(|(_, result)| result.is_ok())); + let id = PluginId::new(BAR_ID).expect("id"); + runtime.activate(&id).expect("bar fixture must activate"); + assert_eq!( + read_settings(&mut runtime), + "show=true;edge=bottom;show_single=false;name_max_chars=32;modules=workspaces" + ); +} From dc0b23610c1f55f551867525166dc5521d464424 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 15:17:52 +0800 Subject: [PATCH 2/3] [CTX-1294] fix: fail closed as Limit when config exceeds a bound (#2192) --- crates/bitty-lua/src/plain_config.rs | 37 +++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/bitty-lua/src/plain_config.rs b/crates/bitty-lua/src/plain_config.rs index 643cf2335..c5a8f3806 100644 --- a/crates/bitty-lua/src/plain_config.rs +++ b/crates/bitty-lua/src/plain_config.rs @@ -15,7 +15,7 @@ use phodopus::{ }, }; -use crate::{DriveOutcome, LuaVm, VmError}; +use crate::{DriveOutcome, LuaVm, SuspendReason, VmError}; /// Owned data accepted at the plugin configuration boundary. #[derive(Debug, Clone, PartialEq)] @@ -98,13 +98,19 @@ impl PlainConfigLimits { pub enum PlainConfigError { /// Source exceeded the configured pre-compile cap. SourceTooLarge, - /// VM construction or budget failure. + /// VM construction failure. + /// + /// Budget exhaustion during evaluation is reported as + /// [`PlainConfigError::Limit`], not here. Vm(VmError), /// Lua compile/runtime failure. Lua(String), /// Returned value has an invalid shape or type. Shape(String), - /// Returned data exceeded an extraction bound. + /// Returned data or evaluation exceeded a declared configuration bound. + /// + /// Covers the extraction bounds (depth, key/value, string, aggregate) and + /// the resource budgets (instruction, wall-clock, memory). Limit(String), } @@ -155,8 +161,13 @@ impl ConfigLuaVm { } reject_duplicate_literal_keys(source)?; match self.vm.drive_chunk(source).map_err(PlainConfigError::Vm)? { + // A resource-budget preemption is a declared-bound violation at the + // configuration boundary, so it fails closed as `Limit` exactly like + // the extraction bounds. Reporting it as anything else would make + // the rejection reason depend on how quickly the evaluation reaches + // the bound, which is timing-dependent under load. DriveOutcome::Suspended { reason, .. } => { - Err(PlainConfigError::Vm(VmError::Suspended { reason })) + Err(PlainConfigError::Limit(suspend_limit_message(&reason))) } DriveOutcome::Failed { message } => Err(PlainConfigError::Lua(message)), DriveOutcome::Ready { stashed } => self.vm.take_plain_result(stashed, self.limits), @@ -164,6 +175,24 @@ impl ConfigLuaVm { } } +/// Render a configuration-VM suspension reason as a `Limit` message. +fn suspend_limit_message(reason: &SuspendReason) -> String { + match reason { + SuspendReason::InstructionBudgetExceeded { used, budget } => format!( + "configuration exceeded the instruction budget ({used} of {budget} instructions)" + ), + SuspendReason::WallClockExceeded { + elapsed_ms, + budget_ms, + } => format!( + "configuration exceeded the wall-clock budget ({elapsed_ms} ms of {budget_ms} ms)" + ), + SuspendReason::MemoryExceeded { used, limit } => { + format!("configuration exceeded the memory budget ({used} of {limit} bytes)") + } + } +} + fn reject_duplicate_literal_keys(source: &str) -> Result<(), PlainConfigError> { let chunk = parse_chunk(source.as_bytes(), BasicInterner::default()) .map_err(|error| PlainConfigError::Lua(format!("{error}")))?; From 75877f0c9295daffb084f4cf297bdc4749516d9a Mon Sep 17 00:00:00 2001 From: reviewer Date: Sat, 17 Oct 2026 16:48:11 +0800 Subject: [PATCH 3/3] [CTX-1294] fix: close plugin config portability gaps --- .../bitty-runtime/src/plugin_runtime/mod.rs | 13 +++++- .../src/plugin_runtime/plugin_settings.rs | 44 ++++++++++--------- .../tests/plugin_settings_file.rs | 29 ++++++++++++ 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/crates/bitty-runtime/src/plugin_runtime/mod.rs b/crates/bitty-runtime/src/plugin_runtime/mod.rs index 5d348851a..fa1b30389 100644 --- a/crates/bitty-runtime/src/plugin_runtime/mod.rs +++ b/crates/bitty-runtime/src/plugin_runtime/mod.rs @@ -1647,8 +1647,17 @@ impl PluginRuntime { .iter() .chain(self.third_party_roots.iter()) .all(|root| root.is_dir()); - let store_available = - self.safe_mode || self.store_root.as_ref().is_none_or(|root| root.is_dir()); + let store_available = match self.store_root.as_ref() { + // Without a configured store there is no unenumerated source that + // could introduce an alias collision. + None => true, + // Safe mode deliberately does not enumerate the configured store, + // so its records cannot be proven absent from the alias registry. + Some(_) if self.safe_mode => false, + // Normal discovery includes the configured store when its root is + // available; preserve the existing directory availability check. + Some(root) => root.is_dir(), + }; self.settings_alias_registry = (roots_available && store_available) .then(|| Self::complete_discovery_ids(registry_ids, &results)) .flatten(); diff --git a/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs b/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs index e84ead198..7e58fbd60 100644 --- a/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs +++ b/crates/bitty-runtime/src/plugin_runtime/plugin_settings.rs @@ -416,7 +416,7 @@ fn read_bounded_regular_file(path: &Path) -> Result, ConfigReadError> { } Err(error) => { return Err(ConfigReadError::Rejected(format!( - "unreadable plugin Lua config: {error}" + "unreadable plugin config: {error}" ))); } } @@ -445,7 +445,7 @@ fn read_bounded_regular_file(path: &Path) -> Result, ConfigReadError> { } Err(error) => { return Err(ConfigReadError::Rejected(format!( - "unreadable plugin Lua config: {error}" + "unreadable plugin config: {error}" ))); } } @@ -468,25 +468,23 @@ fn read_bounded_regular_file(path: &Path) -> Result, ConfigReadError> { "plugin Lua config loading is unsupported on this platform".to_string(), )); - let metadata = file.metadata().map_err(|error| { - ConfigReadError::Rejected(format!("unreadable plugin Lua config: {error}")) - })?; + let metadata = file + .metadata() + .map_err(|error| ConfigReadError::Rejected(format!("unreadable plugin config: {error}")))?; if !metadata.is_file() { return Err(ConfigReadError::Rejected( - "plugin Lua config must be a regular file".to_string(), + "plugin config must be a regular file".to_string(), )); } if metadata.len() > bitty_config::plugin_file::MAX_PLUGIN_FILE_BYTES as u64 { return Err(ConfigReadError::Rejected( - "plugin Lua config exceeds the byte ceiling".to_string(), + "plugin config exceeds the byte ceiling".to_string(), )); } let mut bytes = Vec::new(); file.take(bitty_config::plugin_file::MAX_PLUGIN_FILE_BYTES as u64 + 1) .read_to_end(&mut bytes) - .map_err(|error| { - ConfigReadError::Rejected(format!("unreadable plugin Lua config: {error}")) - })?; + .map_err(|error| ConfigReadError::Rejected(format!("unreadable plugin config: {error}")))?; if bytes.len() as u64 != metadata.len() || bytes.len() > bitty_config::plugin_file::MAX_PLUGIN_FILE_BYTES { @@ -1130,18 +1128,22 @@ mod tests { assert!(loaded.file_error.is_some()); std::fs::remove_dir(plugins.join("xuepoo.bar.lua")).expect("remove directory"); - let fifo = plugins.join("xuepoo.bar.lua"); - rustix::fs::mkfifoat( - rustix::fs::CWD, - &fifo, - rustix::fs::Mode::from_raw_mode(0o600), - ) - .expect("fifo"); - let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); - assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); - assert!(loaded.file_error.is_some()); - std::fs::remove_file(plugins.join("xuepoo.bar.lua")).expect("remove fifo"); + #[cfg(any(target_os = "linux", target_os = "android"))] + { + let fifo = plugins.join("xuepoo.bar.lua"); + rustix::fs::mkfifoat( + rustix::fs::CWD, + &fifo, + rustix::fs::Mode::from_raw_mode(0o600), + ) + .expect("fifo"); + let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); + assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); + assert!(loaded.file_error.is_some()); + std::fs::remove_file(&fifo).expect("remove fifo"); + } + std::os::unix::fs::symlink(&target, plugins.join("xuepoo.bar.toml")).expect("toml symlink"); let loaded = load_plugin_settings(Some(&dir), "xuepoo.bar", &bar_decls(), None); assert_eq!(loaded.source.get("show"), Some(LuaValue::Bool(true))); diff --git a/crates/bitty-runtime/tests/plugin_settings_file.rs b/crates/bitty-runtime/tests/plugin_settings_file.rs index 8adc67c49..18f9031a6 100644 --- a/crates/bitty-runtime/tests/plugin_settings_file.rs +++ b/crates/bitty-runtime/tests/plugin_settings_file.rs @@ -262,6 +262,35 @@ fn short_alias_fails_closed_when_registry_has_error_candidate() { ); } +#[test] +fn short_alias_fails_closed_in_safe_mode_with_configured_store() { + let root = temp_dir("root-safe-mode-configured-store"); + write_bar_plugin(&root); + let store = temp_dir("store-safe-mode-configured-store"); + let config = temp_dir("config-safe-mode-configured-store"); + let plugins = config.join("plugins"); + std::fs::create_dir_all(&plugins).expect("plugins dir"); + std::fs::write(plugins.join("bar.lua"), b"return { show = false }\n").expect("alias"); + + let mut runtime = PluginRuntime::new(PluginRuntimeConfig { + safe_mode: true, + data_dir: Some(temp_dir("data-safe-mode-configured-store")), + store_root: Some(store), + bundled_roots: vec![root], + third_party_roots: Vec::new(), + settings: Rc::new(EmptySettings), + snapshot: Rc::new(UnavailableSnapshot) as Rc, + }); + runtime.set_settings_dir(Some(config)); + runtime.discover(); + let id = PluginId::new(BAR_ID).expect("id"); + runtime.activate(&id).expect("bar fixture must activate"); + assert_eq!( + read_settings(&mut runtime), + "show=true;edge=bottom;show_single=false;name_max_chars=32;modules=workspaces" + ); +} + #[test] fn short_alias_fails_closed_for_disabled_installed_collision() { let root = temp_dir("root-disabled-installed-collision");