Skip to main content

termivar_scanner/
lua_config.rs

1//! Host-owned limits for the opt-in Lua execution boundary.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6pub const HARD_MAX_HISTORY_ENTRIES: usize = 1_024;
7pub const HARD_MAX_MEMORY_BYTES: usize = 256 * 1_024 * 1_024;
8pub const HARD_MAX_TIMEOUT_MS: u64 = 60_000;
9pub const HARD_MAX_SOURCE_BYTES: usize = 1_024 * 1_024;
10pub const HARD_MAX_TOTAL_SOURCE_BYTES: usize = 64 * 1_024 * 1_024;
11pub const HARD_MAX_CONTEXT_BYTES: usize = 1_024 * 1_024;
12pub const HARD_MAX_TARGET_BYTES: usize = 256 * 1_024;
13pub const HARD_MAX_PAYLOAD_BYTES: usize = 512 * 1_024;
14pub const HARD_MAX_PARAMETERS: usize = 1_024;
15pub const HARD_MAX_PARAMETER_KEY_BYTES: usize = 4 * 1_024;
16pub const HARD_MAX_PARAMETER_VALUE_BYTES: usize = 64 * 1_024;
17pub const HARD_MAX_OUTPUT_BYTES: usize = 1_024 * 1_024;
18pub const HARD_MAX_RETURN_BYTES: usize = 1_024 * 1_024;
19pub const HARD_MAX_INSTRUCTIONS: u64 = 100_000_000;
20pub const HARD_MAX_HOOK_INTERVAL: u32 = 10_000;
21pub const HARD_MAX_SCRIPTS: usize = 4_096;
22pub const HARD_MAX_CONCURRENT_EXECUTIONS: usize = 64;
23pub const HARD_MAX_HISTORY_BYTES_PER_SCRIPT: usize = 8 * 1_024 * 1_024;
24pub const HARD_MAX_HISTORY_BYTES_TOTAL: usize = 64 * 1_024 * 1_024;
25
26/// Resource configuration for one host-owned Lua registry.
27///
28/// Every value is validated against a nonzero hard ceiling before the registry
29/// allocates storage or accepts executable source.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LuaEngineConfig {
32    /// Maximum execution-history entries retained per script.
33    pub history_size: usize,
34    /// Maximum retained history bytes per script.
35    pub max_history_bytes_per_script: usize,
36    /// Maximum retained history bytes across the registry.
37    pub max_history_bytes_total: usize,
38    /// Maximum memory per fresh Lua VM, in bytes.
39    pub max_memory_bytes: usize,
40    /// Monotonic wall-clock deadline for one execution, in milliseconds.
41    pub default_timeout_ms: u64,
42    /// Deterministic VM instruction ceiling for one execution.
43    pub instruction_limit: u64,
44    /// Number of VM instructions between host budget checks.
45    pub hook_interval: u32,
46    /// Maximum registered source bytes.
47    pub max_source_bytes: usize,
48    /// Maximum source bytes retained across the registry.
49    pub max_total_source_bytes: usize,
50    /// Maximum aggregate context bytes.
51    pub max_context_bytes: usize,
52    /// Maximum target bytes inside a context.
53    pub max_target_bytes: usize,
54    /// Maximum payload bytes inside a context.
55    pub max_payload_bytes: usize,
56    /// Maximum parameter entries inside a context.
57    pub max_parameters: usize,
58    /// Maximum bytes in one parameter key.
59    pub max_parameter_key_bytes: usize,
60    /// Maximum bytes in one parameter value.
61    pub max_parameter_value_bytes: usize,
62    /// Maximum bytes emitted by one execution.
63    pub max_output_bytes: usize,
64    /// Maximum bytes in the supported scalar return value.
65    pub max_return_bytes: usize,
66    /// Maximum scripts registered at once.
67    pub max_scripts: usize,
68    /// Maximum Lua VMs executing concurrently in this registry.
69    pub max_concurrent_executions: usize,
70}
71
72/// How one Lua configuration field violates its hard contract.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum LuaConfigViolation {
75    Zero,
76    AboveHardMaximum,
77    Inconsistent,
78}
79
80/// Typed configuration validation failure.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct LuaConfigError {
83    field: &'static str,
84    violation: LuaConfigViolation,
85}
86
87impl LuaConfigError {
88    #[must_use]
89    pub fn field(&self) -> &'static str {
90        self.field
91    }
92
93    #[must_use]
94    pub fn violation(&self) -> LuaConfigViolation {
95        self.violation
96    }
97}
98
99impl fmt::Display for LuaConfigError {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        let reason = match self.violation {
102            LuaConfigViolation::Zero => "must be nonzero",
103            LuaConfigViolation::AboveHardMaximum => "exceeds its hard maximum",
104            LuaConfigViolation::Inconsistent => "is inconsistent with another limit",
105        };
106        write!(formatter, "{} {reason}", self.field)
107    }
108}
109
110impl std::error::Error for LuaConfigError {}
111
112impl LuaEngineConfig {
113    /// Minimal limits for local tests and constrained hosts.
114    #[must_use]
115    pub fn minimal() -> Self {
116        Self {
117            history_size: 10,
118            max_history_bytes_per_script: 128 * 1_024,
119            max_history_bytes_total: 512 * 1_024,
120            max_memory_bytes: 10 * 1_024 * 1_024,
121            default_timeout_ms: 1_000,
122            instruction_limit: 1_000_000,
123            hook_interval: 1_000,
124            max_source_bytes: 64 * 1_024,
125            max_total_source_bytes: 256 * 1_024,
126            max_context_bytes: 128 * 1_024,
127            max_target_bytes: 16 * 1_024,
128            max_payload_bytes: 64 * 1_024,
129            max_parameters: 128,
130            max_parameter_key_bytes: 1_024,
131            max_parameter_value_bytes: 16 * 1_024,
132            max_output_bytes: 64 * 1_024,
133            max_return_bytes: 64 * 1_024,
134            max_scripts: 64,
135            max_concurrent_executions: 4,
136        }
137    }
138
139    /// Extended limits for an explicitly provisioned host.
140    #[must_use]
141    pub fn extended() -> Self {
142        Self {
143            history_size: 500,
144            max_history_bytes_per_script: 4 * 1_024 * 1_024,
145            max_history_bytes_total: 32 * 1_024 * 1_024,
146            max_memory_bytes: 100 * 1_024 * 1_024,
147            default_timeout_ms: 30_000,
148            instruction_limit: 50_000_000,
149            hook_interval: 10_000,
150            max_source_bytes: HARD_MAX_SOURCE_BYTES,
151            max_total_source_bytes: HARD_MAX_TOTAL_SOURCE_BYTES,
152            max_context_bytes: HARD_MAX_CONTEXT_BYTES,
153            max_target_bytes: HARD_MAX_TARGET_BYTES,
154            max_payload_bytes: HARD_MAX_PAYLOAD_BYTES,
155            max_parameters: HARD_MAX_PARAMETERS,
156            max_parameter_key_bytes: HARD_MAX_PARAMETER_KEY_BYTES,
157            max_parameter_value_bytes: HARD_MAX_PARAMETER_VALUE_BYTES,
158            max_output_bytes: HARD_MAX_OUTPUT_BYTES,
159            max_return_bytes: HARD_MAX_RETURN_BYTES,
160            max_scripts: 2_048,
161            max_concurrent_executions: 32,
162        }
163    }
164
165    /// Validates every configured limit before it can control allocation or execution.
166    pub fn validate(&self) -> Result<(), LuaConfigError> {
167        validate_usize("history_size", self.history_size, HARD_MAX_HISTORY_ENTRIES)?;
168        validate_usize(
169            "max_history_bytes_per_script",
170            self.max_history_bytes_per_script,
171            HARD_MAX_HISTORY_BYTES_PER_SCRIPT,
172        )?;
173        validate_usize(
174            "max_history_bytes_total",
175            self.max_history_bytes_total,
176            HARD_MAX_HISTORY_BYTES_TOTAL,
177        )?;
178        validate_usize(
179            "max_memory_bytes",
180            self.max_memory_bytes,
181            HARD_MAX_MEMORY_BYTES,
182        )?;
183        validate_u64(
184            "default_timeout_ms",
185            self.default_timeout_ms,
186            HARD_MAX_TIMEOUT_MS,
187        )?;
188        validate_u64(
189            "instruction_limit",
190            self.instruction_limit,
191            HARD_MAX_INSTRUCTIONS,
192        )?;
193        validate_u32("hook_interval", self.hook_interval, HARD_MAX_HOOK_INTERVAL)?;
194        validate_usize(
195            "max_source_bytes",
196            self.max_source_bytes,
197            HARD_MAX_SOURCE_BYTES,
198        )?;
199        validate_usize(
200            "max_total_source_bytes",
201            self.max_total_source_bytes,
202            HARD_MAX_TOTAL_SOURCE_BYTES,
203        )?;
204        validate_usize(
205            "max_context_bytes",
206            self.max_context_bytes,
207            HARD_MAX_CONTEXT_BYTES,
208        )?;
209        validate_usize(
210            "max_target_bytes",
211            self.max_target_bytes,
212            HARD_MAX_TARGET_BYTES,
213        )?;
214        validate_usize(
215            "max_payload_bytes",
216            self.max_payload_bytes,
217            HARD_MAX_PAYLOAD_BYTES,
218        )?;
219        validate_usize("max_parameters", self.max_parameters, HARD_MAX_PARAMETERS)?;
220        validate_usize(
221            "max_parameter_key_bytes",
222            self.max_parameter_key_bytes,
223            HARD_MAX_PARAMETER_KEY_BYTES,
224        )?;
225        validate_usize(
226            "max_parameter_value_bytes",
227            self.max_parameter_value_bytes,
228            HARD_MAX_PARAMETER_VALUE_BYTES,
229        )?;
230        validate_usize(
231            "max_output_bytes",
232            self.max_output_bytes,
233            HARD_MAX_OUTPUT_BYTES,
234        )?;
235        validate_usize(
236            "max_return_bytes",
237            self.max_return_bytes,
238            HARD_MAX_RETURN_BYTES,
239        )?;
240        validate_usize("max_scripts", self.max_scripts, HARD_MAX_SCRIPTS)?;
241        validate_usize(
242            "max_concurrent_executions",
243            self.max_concurrent_executions,
244            HARD_MAX_CONCURRENT_EXECUTIONS,
245        )?;
246
247        if u64::from(self.hook_interval) > self.instruction_limit {
248            return Err(LuaConfigError {
249                field: "hook_interval",
250                violation: LuaConfigViolation::Inconsistent,
251            });
252        }
253        if self.max_history_bytes_per_script > self.max_history_bytes_total {
254            return Err(LuaConfigError {
255                field: "max_history_bytes_per_script",
256                violation: LuaConfigViolation::Inconsistent,
257            });
258        }
259        if self.max_source_bytes > self.max_total_source_bytes {
260            return Err(LuaConfigError {
261                field: "max_source_bytes",
262                violation: LuaConfigViolation::Inconsistent,
263            });
264        }
265        for (field, value) in [
266            ("max_target_bytes", self.max_target_bytes),
267            ("max_payload_bytes", self.max_payload_bytes),
268            ("max_parameter_key_bytes", self.max_parameter_key_bytes),
269            ("max_parameter_value_bytes", self.max_parameter_value_bytes),
270        ] {
271            if value > self.max_context_bytes {
272                return Err(LuaConfigError {
273                    field,
274                    violation: LuaConfigViolation::Inconsistent,
275                });
276            }
277        }
278        Ok(())
279    }
280}
281
282impl Default for LuaEngineConfig {
283    fn default() -> Self {
284        Self {
285            history_size: 100,
286            max_history_bytes_per_script: 1_024 * 1_024,
287            max_history_bytes_total: 8 * 1_024 * 1_024,
288            max_memory_bytes: 50 * 1_024 * 1_024,
289            default_timeout_ms: 5_000,
290            instruction_limit: 10_000_000,
291            hook_interval: 5_000,
292            max_source_bytes: 256 * 1_024,
293            max_total_source_bytes: 16 * 1_024 * 1_024,
294            max_context_bytes: 256 * 1_024,
295            max_target_bytes: 32 * 1_024,
296            max_payload_bytes: 128 * 1_024,
297            max_parameters: 256,
298            max_parameter_key_bytes: 1_024,
299            max_parameter_value_bytes: 32 * 1_024,
300            max_output_bytes: 128 * 1_024,
301            max_return_bytes: 128 * 1_024,
302            max_scripts: 256,
303            max_concurrent_executions: 8,
304        }
305    }
306}
307
308fn validate_usize(field: &'static str, value: usize, maximum: usize) -> Result<(), LuaConfigError> {
309    if value == 0 {
310        return Err(LuaConfigError {
311            field,
312            violation: LuaConfigViolation::Zero,
313        });
314    }
315    if value > maximum {
316        return Err(LuaConfigError {
317            field,
318            violation: LuaConfigViolation::AboveHardMaximum,
319        });
320    }
321    Ok(())
322}
323
324fn validate_u64(field: &'static str, value: u64, maximum: u64) -> Result<(), LuaConfigError> {
325    if value == 0 {
326        return Err(LuaConfigError {
327            field,
328            violation: LuaConfigViolation::Zero,
329        });
330    }
331    if value > maximum {
332        return Err(LuaConfigError {
333            field,
334            violation: LuaConfigViolation::AboveHardMaximum,
335        });
336    }
337    Ok(())
338}
339
340fn validate_u32(field: &'static str, value: u32, maximum: u32) -> Result<(), LuaConfigError> {
341    if value == 0 {
342        return Err(LuaConfigError {
343            field,
344            violation: LuaConfigViolation::Zero,
345        });
346    }
347    if value > maximum {
348        return Err(LuaConfigError {
349            field,
350            violation: LuaConfigViolation::AboveHardMaximum,
351        });
352    }
353    Ok(())
354}
355
356#[derive(Serialize, Deserialize)]
357struct LuaEngineConfigWire {
358    history_size: u64,
359    max_history_bytes_per_script: u64,
360    max_history_bytes_total: u64,
361    max_memory_bytes: u64,
362    default_timeout_ms: u64,
363    instruction_limit: u64,
364    hook_interval: u32,
365    max_source_bytes: u64,
366    max_total_source_bytes: u64,
367    max_context_bytes: u64,
368    max_target_bytes: u64,
369    max_payload_bytes: u64,
370    max_parameters: u64,
371    max_parameter_key_bytes: u64,
372    max_parameter_value_bytes: u64,
373    max_output_bytes: u64,
374    max_return_bytes: u64,
375    max_scripts: u64,
376    max_concurrent_executions: u64,
377}
378
379impl Serialize for LuaEngineConfig {
380    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
381    where
382        S: serde::Serializer,
383    {
384        let to_u64 = |value: usize| {
385            u64::try_from(value)
386                .map_err(|_| serde::ser::Error::custom("Lua limit does not fit u64"))
387        };
388        LuaEngineConfigWire {
389            history_size: to_u64(self.history_size)?,
390            max_history_bytes_per_script: to_u64(self.max_history_bytes_per_script)?,
391            max_history_bytes_total: to_u64(self.max_history_bytes_total)?,
392            max_memory_bytes: to_u64(self.max_memory_bytes)?,
393            default_timeout_ms: self.default_timeout_ms,
394            instruction_limit: self.instruction_limit,
395            hook_interval: self.hook_interval,
396            max_source_bytes: to_u64(self.max_source_bytes)?,
397            max_total_source_bytes: to_u64(self.max_total_source_bytes)?,
398            max_context_bytes: to_u64(self.max_context_bytes)?,
399            max_target_bytes: to_u64(self.max_target_bytes)?,
400            max_payload_bytes: to_u64(self.max_payload_bytes)?,
401            max_parameters: to_u64(self.max_parameters)?,
402            max_parameter_key_bytes: to_u64(self.max_parameter_key_bytes)?,
403            max_parameter_value_bytes: to_u64(self.max_parameter_value_bytes)?,
404            max_output_bytes: to_u64(self.max_output_bytes)?,
405            max_return_bytes: to_u64(self.max_return_bytes)?,
406            max_scripts: to_u64(self.max_scripts)?,
407            max_concurrent_executions: to_u64(self.max_concurrent_executions)?,
408        }
409        .serialize(serializer)
410    }
411}
412
413impl<'de> Deserialize<'de> for LuaEngineConfig {
414    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
415    where
416        D: serde::Deserializer<'de>,
417    {
418        let wire = LuaEngineConfigWire::deserialize(deserializer)?;
419        let to_usize = |field: &'static str, value: u64| {
420            usize::try_from(value)
421                .map_err(|_| serde::de::Error::custom(format!("{field} does not fit usize")))
422        };
423        let config = Self {
424            history_size: to_usize("history_size", wire.history_size)?,
425            max_history_bytes_per_script: to_usize(
426                "max_history_bytes_per_script",
427                wire.max_history_bytes_per_script,
428            )?,
429            max_history_bytes_total: to_usize(
430                "max_history_bytes_total",
431                wire.max_history_bytes_total,
432            )?,
433            max_memory_bytes: to_usize("max_memory_bytes", wire.max_memory_bytes)?,
434            default_timeout_ms: wire.default_timeout_ms,
435            instruction_limit: wire.instruction_limit,
436            hook_interval: wire.hook_interval,
437            max_source_bytes: to_usize("max_source_bytes", wire.max_source_bytes)?,
438            max_total_source_bytes: to_usize(
439                "max_total_source_bytes",
440                wire.max_total_source_bytes,
441            )?,
442            max_context_bytes: to_usize("max_context_bytes", wire.max_context_bytes)?,
443            max_target_bytes: to_usize("max_target_bytes", wire.max_target_bytes)?,
444            max_payload_bytes: to_usize("max_payload_bytes", wire.max_payload_bytes)?,
445            max_parameters: to_usize("max_parameters", wire.max_parameters)?,
446            max_parameter_key_bytes: to_usize(
447                "max_parameter_key_bytes",
448                wire.max_parameter_key_bytes,
449            )?,
450            max_parameter_value_bytes: to_usize(
451                "max_parameter_value_bytes",
452                wire.max_parameter_value_bytes,
453            )?,
454            max_output_bytes: to_usize("max_output_bytes", wire.max_output_bytes)?,
455            max_return_bytes: to_usize("max_return_bytes", wire.max_return_bytes)?,
456            max_scripts: to_usize("max_scripts", wire.max_scripts)?,
457            max_concurrent_executions: to_usize(
458                "max_concurrent_executions",
459                wire.max_concurrent_executions,
460            )?,
461        };
462        config.validate().map_err(serde::de::Error::custom)?;
463        Ok(config)
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn built_in_profiles_validate() {
473        for config in [
474            LuaEngineConfig::minimal(),
475            LuaEngineConfig::default(),
476            LuaEngineConfig::extended(),
477        ] {
478            assert_eq!(config.validate(), Ok(()));
479        }
480    }
481
482    #[test]
483    fn zero_and_huge_values_fail_before_allocation() {
484        let mut zero = LuaEngineConfig::minimal();
485        zero.max_memory_bytes = 0;
486        assert_eq!(
487            zero.validate(),
488            Err(LuaConfigError {
489                field: "max_memory_bytes",
490                violation: LuaConfigViolation::Zero,
491            })
492        );
493
494        let mut huge = LuaEngineConfig::minimal();
495        huge.history_size = HARD_MAX_HISTORY_ENTRIES + 1;
496        assert_eq!(
497            huge.validate(),
498            Err(LuaConfigError {
499                field: "history_size",
500                violation: LuaConfigViolation::AboveHardMaximum,
501            })
502        );
503    }
504
505    #[test]
506    fn inconsistent_limits_fail_closed() {
507        let mut config = LuaEngineConfig::minimal();
508        config.hook_interval = 1_001;
509        config.instruction_limit = 1_000;
510        assert_eq!(
511            config.validate(),
512            Err(LuaConfigError {
513                field: "hook_interval",
514                violation: LuaConfigViolation::Inconsistent,
515            })
516        );
517
518        let mut config = LuaEngineConfig::minimal();
519        config.max_context_bytes = 1;
520        assert_eq!(
521            config.validate(),
522            Err(LuaConfigError {
523                field: "max_target_bytes",
524                violation: LuaConfigViolation::Inconsistent,
525            })
526        );
527
528        let mut config = LuaEngineConfig::minimal();
529        config.max_total_source_bytes = config.max_source_bytes - 1;
530        assert_eq!(
531            config.validate(),
532            Err(LuaConfigError {
533                field: "max_source_bytes",
534                violation: LuaConfigViolation::Inconsistent,
535            })
536        );
537    }
538
539    #[test]
540    fn serde_uses_fixed_width_wire_values_and_revalidates() {
541        let config = LuaEngineConfig::default();
542        let wire = serde_json::to_string(&config).expect("serialize config");
543        let round_trip: LuaEngineConfig = serde_json::from_str(&wire).expect("deserialize config");
544        assert_eq!(round_trip, config);
545
546        let invalid = wire.replace(
547            &format!("\"max_memory_bytes\":{}", config.max_memory_bytes),
548            "\"max_memory_bytes\":0",
549        );
550        let error = serde_json::from_str::<LuaEngineConfig>(&invalid).unwrap_err();
551        assert!(error.to_string().contains("max_memory_bytes"));
552    }
553}