1use crate::lua_config::{LuaConfigError, LuaEngineConfig};
18use history::{elapsed_ms, ExecutionProvenance};
19use registry::RegistryState;
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22use source::{hex_digest, read_registered_source, stable_script_id, validate_identifier};
23use std::collections::BTreeMap;
24use std::fmt;
25use std::path::Path;
26use std::str::FromStr;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::{Arc, Mutex};
29use std::time::Instant;
30use tokio::sync::Semaphore;
31
32mod execution;
33mod history;
34mod limits;
35mod registry;
36mod source;
37mod vm;
38
39#[cfg(test)]
40use limits::{
41 enforce_hook_controls, instruction_quantum_status, terminal_control_error, StickyAbort,
42};
43#[cfg(test)]
44use mlua::Error as MluaError;
45#[cfg(test)]
46use std::cell::Cell;
47#[cfg(test)]
48use std::task::Poll;
49#[cfg(test)]
50use std::time::Duration;
51#[cfg(test)]
52use vm::execute_snapshot;
53
54const MAX_SCRIPT_NAME_BYTES: usize = 128;
55const MAX_SCRIPT_VERSION_BYTES: usize = 64;
56const REGISTERED_CHUNK_NAME: &str = "@venom:registered";
57const ABORT_CANCELLED: &str = "venom:cancelled";
58const ABORT_DEADLINE: &str = "venom:deadline";
59const ABORT_INSTRUCTION: &str = "venom:instruction-limit";
60const ABORT_OUTPUT: &str = "venom:output-limit";
61const ABORT_OUTPUT_ENCODING: &str = "venom:output-encoding";
62const ABORT_OUTPUT_TYPE: &str = "venom:output-type";
63const ABORT_OUTPUT_NUMBER: &str = "venom:output-number";
64const IMMUTABLE_CONTEXT: &str = "venom:immutable-context";
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum ScriptCategory {
70 Web,
71 Dns,
72 Smb,
73 Ssh,
74 Database,
75}
76
77impl ScriptCategory {
78 #[must_use]
79 pub fn as_str(self) -> &'static str {
80 match self {
81 Self::Web => "web",
82 Self::Dns => "dns",
83 Self::Smb => "smb",
84 Self::Ssh => "ssh",
85 Self::Database => "database",
86 }
87 }
88
89 #[must_use]
90 pub fn all() -> &'static [Self] {
91 &[Self::Web, Self::Dns, Self::Smb, Self::Ssh, Self::Database]
92 }
93}
94
95impl FromStr for ScriptCategory {
96 type Err = LuaRegistrationError;
97
98 fn from_str(value: &str) -> Result<Self, Self::Err> {
99 if value.eq_ignore_ascii_case("web") {
100 Ok(Self::Web)
101 } else if value.eq_ignore_ascii_case("dns") {
102 Ok(Self::Dns)
103 } else if value.eq_ignore_ascii_case("smb") {
104 Ok(Self::Smb)
105 } else if value.eq_ignore_ascii_case("ssh") {
106 Ok(Self::Ssh)
107 } else if value.eq_ignore_ascii_case("database") {
108 Ok(Self::Database)
109 } else {
110 Err(LuaRegistrationError::InvalidCategory)
111 }
112 }
113}
114
115impl fmt::Display for ScriptCategory {
116 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117 formatter.write_str(self.as_str())
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum LuaRegistrationError {
124 InvalidConfig(LuaConfigError),
125 InvalidName,
126 InvalidVersion,
127 InvalidCategory,
128 InvalidPath,
129 OutsideApprovedRoot,
130 SymlinkRejected,
131 NotRegularFile,
132 SourceTooLarge,
133 SourceNotUtf8,
134 SourceChangedDuringRegistration,
135 SourceReadFailed,
136}
137
138impl fmt::Display for LuaRegistrationError {
139 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140 let message = match self {
141 Self::InvalidConfig(_) => "invalid Lua engine configuration",
142 Self::InvalidName => "invalid Lua script name",
143 Self::InvalidVersion => "invalid Lua script version",
144 Self::InvalidCategory => "invalid Lua script category",
145 Self::InvalidPath => "invalid Lua script path",
146 Self::OutsideApprovedRoot => "Lua script is outside the approved root",
147 Self::SymlinkRejected => "Lua script path contains a symbolic link",
148 Self::NotRegularFile => "Lua script source is not a regular file",
149 Self::SourceTooLarge => "Lua script source exceeds its configured limit",
150 Self::SourceNotUtf8 => "Lua script source must be UTF-8 text",
151 Self::SourceChangedDuringRegistration => {
152 "Lua script source changed during registration"
153 },
154 Self::SourceReadFailed => "Lua script source could not be read",
155 };
156 formatter.write_str(message)
157 }
158}
159
160impl std::error::Error for LuaRegistrationError {
161 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
162 match self {
163 Self::InvalidConfig(error) => Some(error),
164 _ => None,
165 }
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct LuaScriptManifest {
172 id: String,
173 name: String,
174 version: String,
175 categories: Vec<ScriptCategory>,
176 enabled: bool,
177 source_bytes: u64,
178 source_sha256: String,
179}
180
181impl LuaScriptManifest {
182 #[must_use]
183 pub fn id(&self) -> &str {
184 &self.id
185 }
186
187 #[must_use]
188 pub fn name(&self) -> &str {
189 &self.name
190 }
191
192 #[must_use]
193 pub fn version(&self) -> &str {
194 &self.version
195 }
196
197 #[must_use]
198 pub fn categories(&self) -> &[ScriptCategory] {
199 &self.categories
200 }
201
202 #[must_use]
203 pub fn enabled(&self) -> bool {
204 self.enabled
205 }
206
207 #[must_use]
208 pub fn source_bytes(&self) -> u64 {
209 self.source_bytes
210 }
211
212 #[must_use]
213 pub fn source_sha256(&self) -> &str {
214 &self.source_sha256
215 }
216}
217
218#[derive(Clone)]
220pub struct LuaScript {
221 name: String,
222 version: String,
223 categories: Vec<ScriptCategory>,
224 enabled: bool,
225 source: Arc<str>,
226 source_bytes: u64,
227 source_digest: [u8; 32],
228}
229
230impl fmt::Debug for LuaScript {
231 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232 formatter
233 .debug_struct("LuaScript")
234 .field("id", &self.id())
235 .field("name", &self.name)
236 .field("version", &self.version)
237 .field("categories", &self.categories)
238 .field("enabled", &self.enabled)
239 .field("source_bytes", &self.source_bytes)
240 .finish_non_exhaustive()
241 }
242}
243
244impl LuaScript {
245 pub fn new_safe(
246 name: impl Into<String>,
247 script_path: impl AsRef<Path>,
248 approved_root: &Path,
249 ) -> Result<Self, LuaRegistrationError> {
250 Self::new_safe_with_config(
251 name,
252 script_path,
253 approved_root,
254 &LuaEngineConfig::default(),
255 )
256 }
257
258 pub fn new_safe_with_config(
259 name: impl Into<String>,
260 script_path: impl AsRef<Path>,
261 approved_root: &Path,
262 config: &LuaEngineConfig,
263 ) -> Result<Self, LuaRegistrationError> {
264 config
265 .validate()
266 .map_err(LuaRegistrationError::InvalidConfig)?;
267 let name = name.into();
268 validate_identifier(&name, MAX_SCRIPT_NAME_BYTES)
269 .map_err(|()| LuaRegistrationError::InvalidName)?;
270 let source =
271 read_registered_source(script_path.as_ref(), approved_root, config.max_source_bytes)?;
272 let source_bytes =
273 u64::try_from(source.len()).map_err(|_| LuaRegistrationError::SourceTooLarge)?;
274 let source_digest: [u8; 32] = Sha256::digest(source.as_bytes()).into();
275 Ok(Self {
276 name,
277 version: "1.0.0".to_owned(),
278 categories: Vec::new(),
279 enabled: true,
280 source: Arc::from(source),
281 source_bytes,
282 source_digest,
283 })
284 }
285
286 pub fn with_version(
287 mut self,
288 version: impl Into<String>,
289 ) -> Result<Self, LuaRegistrationError> {
290 let version = version.into();
291 validate_identifier(&version, MAX_SCRIPT_VERSION_BYTES)
292 .map_err(|()| LuaRegistrationError::InvalidVersion)?;
293 self.version = version;
294 Ok(self)
295 }
296
297 #[must_use]
298 pub fn with_categories(mut self, mut categories: Vec<ScriptCategory>) -> Self {
299 categories.sort_unstable();
300 categories.dedup();
301 self.categories = categories;
302 self
303 }
304
305 #[must_use]
306 pub fn with_enabled(mut self, enabled: bool) -> Self {
307 self.enabled = enabled;
308 self
309 }
310
311 #[must_use]
312 pub fn id(&self) -> String {
313 stable_script_id(&self.name, &self.version, &self.source_digest)
314 }
315
316 #[must_use]
317 pub fn manifest(&self) -> LuaScriptManifest {
318 self.manifest_with_enabled(self.enabled)
319 }
320
321 fn manifest_with_enabled(&self, enabled: bool) -> LuaScriptManifest {
322 LuaScriptManifest {
323 id: self.id(),
324 name: self.name.clone(),
325 version: self.version.clone(),
326 categories: self.categories.clone(),
327 enabled,
328 source_bytes: self.source_bytes,
329 source_sha256: hex_digest(&self.source_digest),
330 }
331 }
332}
333
334#[derive(Clone)]
336pub struct LuaContext {
337 target: String,
338 payload: String,
339 parameters: BTreeMap<String, String>,
340}
341
342impl fmt::Debug for LuaContext {
343 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
344 formatter
345 .debug_struct("LuaContext")
346 .field("target_bytes", &self.target.len())
347 .field("payload_bytes", &self.payload.len())
348 .field("parameter_count", &self.parameters.len())
349 .finish()
350 }
351}
352
353impl LuaContext {
354 #[must_use]
355 pub fn new(target: impl Into<String>) -> Self {
356 Self {
357 target: target.into(),
358 payload: String::new(),
359 parameters: BTreeMap::new(),
360 }
361 }
362
363 #[must_use]
364 pub fn with_payload(mut self, payload: impl Into<String>) -> Self {
365 self.payload = payload.into();
366 self
367 }
368
369 #[must_use]
370 pub fn with_parameter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
371 self.parameters.insert(key.into(), value.into());
372 self
373 }
374
375 fn validate(&self, config: &LuaEngineConfig) -> Result<(), LuaExecutionError> {
376 if self.target.len() > config.max_target_bytes {
377 return Err(LuaExecutionError::ContextTargetLimit);
378 }
379 if self.payload.len() > config.max_payload_bytes {
380 return Err(LuaExecutionError::ContextPayloadLimit);
381 }
382 if self.parameters.len() > config.max_parameters {
383 return Err(LuaExecutionError::ContextParameterCountLimit);
384 }
385 let mut total = self
386 .target
387 .len()
388 .checked_add(self.payload.len())
389 .ok_or(LuaExecutionError::ContextTotalLimit)?;
390 for (key, value) in &self.parameters {
391 if key.len() > config.max_parameter_key_bytes {
392 return Err(LuaExecutionError::ContextParameterKeyLimit);
393 }
394 if value.len() > config.max_parameter_value_bytes {
395 return Err(LuaExecutionError::ContextParameterValueLimit);
396 }
397 total = total
398 .checked_add(key.len())
399 .and_then(|bytes| bytes.checked_add(value.len()))
400 .ok_or(LuaExecutionError::ContextTotalLimit)?;
401 if total > config.max_context_bytes {
402 return Err(LuaExecutionError::ContextTotalLimit);
403 }
404 }
405 if total > config.max_context_bytes {
406 return Err(LuaExecutionError::ContextTotalLimit);
407 }
408 Ok(())
409 }
410}
411
412#[derive(Clone, Default)]
414pub struct LuaCancellationToken {
415 cancelled: Arc<AtomicBool>,
416}
417
418impl fmt::Debug for LuaCancellationToken {
419 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
420 formatter
421 .debug_struct("LuaCancellationToken")
422 .field("cancelled", &self.is_cancelled())
423 .finish()
424 }
425}
426
427impl LuaCancellationToken {
428 #[must_use]
429 pub fn new() -> Self {
430 Self::default()
431 }
432
433 pub fn cancel(&self) {
434 self.cancelled.store(true, Ordering::SeqCst);
435 }
436
437 #[must_use]
438 pub fn is_cancelled(&self) -> bool {
439 self.cancelled.load(Ordering::SeqCst)
440 }
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(rename_all = "snake_case")]
445pub enum LuaExecutionStatus {
446 Completed,
447 Rejected,
448 Failed,
449 TimedOut,
450 Cancelled,
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(rename_all = "snake_case")]
455pub enum LuaExecutionError {
456 ScriptDisabled,
457 ConcurrencyLimit,
458 ContextTargetLimit,
459 ContextPayloadLimit,
460 ContextParameterCountLimit,
461 ContextParameterKeyLimit,
462 ContextParameterValueLimit,
463 ContextTotalLimit,
464 Syntax,
465 Runtime,
466 MemoryLimit,
467 InstructionLimit,
468 DeadlineExceeded,
469 Cancelled,
470 OutputLimit,
471 OutputNotUtf8,
472 UnsupportedOutputType,
473 NonFiniteOutputNumber,
474 ReturnLimit,
475 ReturnNotUtf8,
476 NonFiniteReturnNumber,
477 UnsupportedReturnType,
478 MultipleReturnValues,
479 HostFailure,
480}
481
482impl LuaExecutionError {
483 #[must_use]
484 pub fn status(self) -> LuaExecutionStatus {
485 match self {
486 Self::ScriptDisabled
487 | Self::ConcurrencyLimit
488 | Self::ContextTargetLimit
489 | Self::ContextPayloadLimit
490 | Self::ContextParameterCountLimit
491 | Self::ContextParameterKeyLimit
492 | Self::ContextParameterValueLimit
493 | Self::ContextTotalLimit => LuaExecutionStatus::Rejected,
494 Self::InstructionLimit | Self::DeadlineExceeded => LuaExecutionStatus::TimedOut,
495 Self::Cancelled => LuaExecutionStatus::Cancelled,
496 _ => LuaExecutionStatus::Failed,
497 }
498 }
499}
500
501impl fmt::Display for LuaExecutionError {
502 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
503 formatter.write_str(match self {
504 Self::ScriptDisabled => "script is disabled",
505 Self::ConcurrencyLimit => "concurrent execution limit reached",
506 Self::ContextTargetLimit => "context target limit exceeded",
507 Self::ContextPayloadLimit => "context payload limit exceeded",
508 Self::ContextParameterCountLimit => "context parameter count limit exceeded",
509 Self::ContextParameterKeyLimit => "context parameter key limit exceeded",
510 Self::ContextParameterValueLimit => "context parameter value limit exceeded",
511 Self::ContextTotalLimit => "context total limit exceeded",
512 Self::Syntax => "script syntax error",
513 Self::Runtime => "script runtime error",
514 Self::MemoryLimit => "Lua VM memory limit exceeded",
515 Self::InstructionLimit => "Lua VM instruction limit exceeded",
516 Self::DeadlineExceeded => "Lua execution deadline exceeded",
517 Self::Cancelled => "Lua execution cancelled",
518 Self::OutputLimit => "Lua output limit exceeded",
519 Self::OutputNotUtf8 => "Lua output must be UTF-8",
520 Self::UnsupportedOutputType => "Lua emitted an unsupported value type",
521 Self::NonFiniteOutputNumber => "Lua emitted a non-finite number",
522 Self::ReturnLimit => "Lua return value limit exceeded",
523 Self::ReturnNotUtf8 => "Lua return string must be UTF-8",
524 Self::NonFiniteReturnNumber => "Lua returned a non-finite number",
525 Self::UnsupportedReturnType => "Lua returned an unsupported value type",
526 Self::MultipleReturnValues => "Lua returned more than one value",
527 Self::HostFailure => "Lua host failure",
528 })
529 }
530}
531
532impl std::error::Error for LuaExecutionError {}
533
534#[derive(Clone, PartialEq)]
539pub enum LuaReturnValue {
540 Boolean(bool),
541 Integer(i64),
542 Number(f64),
543 String(String),
544}
545
546impl fmt::Debug for LuaReturnValue {
547 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
548 match self {
549 Self::Boolean(_) => formatter.write_str("Boolean(<redacted>)"),
550 Self::Integer(_) => formatter.write_str("Integer(<redacted>)"),
551 Self::Number(_) => formatter.write_str("Number(<redacted>)"),
552 Self::String(value) => formatter
553 .debug_struct("String")
554 .field("bytes", &value.len())
555 .finish(),
556 }
557 }
558}
559
560impl LuaReturnValue {
561 fn retained_bytes(&self) -> usize {
562 match self {
563 Self::Boolean(_) => 1,
564 Self::Integer(_) | Self::Number(_) => 8,
565 Self::String(value) => value.len(),
566 }
567 }
568}
569
570#[derive(Clone)]
571pub struct LuaExecutionResult {
572 script_id: String,
573 script_version: String,
574 source_sha256: String,
575 status: LuaExecutionStatus,
576 error: Option<LuaExecutionError>,
577 output: String,
578 return_value: Option<LuaReturnValue>,
579 execution_time_ms: u64,
580}
581
582impl fmt::Debug for LuaExecutionResult {
583 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
584 formatter
585 .debug_struct("LuaExecutionResult")
586 .field("script_id", &self.script_id)
587 .field("script_version", &self.script_version)
588 .field("source_sha256", &self.source_sha256)
589 .field("status", &self.status)
590 .field("error", &self.error)
591 .field("output_bytes", &self.output.len())
592 .field(
593 "return_bytes",
594 &self
595 .return_value
596 .as_ref()
597 .map(LuaReturnValue::retained_bytes),
598 )
599 .field("execution_time_ms", &self.execution_time_ms)
600 .finish()
601 }
602}
603
604impl LuaExecutionResult {
605 #[must_use]
606 pub fn script_id(&self) -> &str {
607 &self.script_id
608 }
609 #[must_use]
610 pub fn script_version(&self) -> &str {
611 &self.script_version
612 }
613 #[must_use]
614 pub fn source_sha256(&self) -> &str {
615 &self.source_sha256
616 }
617 #[must_use]
618 pub fn status(&self) -> LuaExecutionStatus {
619 self.status
620 }
621 #[must_use]
622 pub fn success(&self) -> bool {
623 self.status == LuaExecutionStatus::Completed
624 }
625 #[must_use]
626 pub fn error(&self) -> Option<LuaExecutionError> {
627 self.error
628 }
629 #[must_use]
630 pub fn output(&self) -> &str {
631 &self.output
632 }
633 #[must_use]
634 pub fn return_value(&self) -> Option<&LuaReturnValue> {
635 self.return_value.as_ref()
636 }
637 #[must_use]
638 pub fn execution_time_ms(&self) -> u64 {
639 self.execution_time_ms
640 }
641 fn completed(
642 provenance: ExecutionProvenance,
643 output: String,
644 return_value: Option<LuaReturnValue>,
645 started: Instant,
646 ) -> Self {
647 Self {
648 script_id: provenance.script_id,
649 script_version: provenance.script_version,
650 source_sha256: provenance.source_sha256,
651 status: LuaExecutionStatus::Completed,
652 error: None,
653 output,
654 return_value,
655 execution_time_ms: elapsed_ms(started),
656 }
657 }
658
659 fn failed(provenance: ExecutionProvenance, error: LuaExecutionError, started: Instant) -> Self {
660 Self {
661 script_id: provenance.script_id,
662 script_version: provenance.script_version,
663 source_sha256: provenance.source_sha256,
664 status: error.status(),
665 error: Some(error),
666 output: String::new(),
667 return_value: None,
668 execution_time_ms: elapsed_ms(started),
669 }
670 }
671}
672
673#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679pub struct LuaExecutionReceipt {
680 script_id: String,
681 script_version: String,
682 source_sha256: String,
683 status: LuaExecutionStatus,
684 error: Option<LuaExecutionError>,
685 execution_time_ms: u64,
686}
687
688impl LuaExecutionReceipt {
689 #[must_use]
690 pub fn script_id(&self) -> &str {
691 &self.script_id
692 }
693
694 #[must_use]
695 pub fn script_version(&self) -> &str {
696 &self.script_version
697 }
698
699 #[must_use]
700 pub fn source_sha256(&self) -> &str {
701 &self.source_sha256
702 }
703
704 #[must_use]
705 pub fn status(&self) -> LuaExecutionStatus {
706 self.status
707 }
708
709 #[must_use]
710 pub fn error(&self) -> Option<LuaExecutionError> {
711 self.error
712 }
713
714 #[must_use]
715 pub fn execution_time_ms(&self) -> u64 {
716 self.execution_time_ms
717 }
718
719 fn from_result(result: &LuaExecutionResult) -> Self {
720 Self {
721 script_id: result.script_id.clone(),
722 script_version: result.script_version.clone(),
723 source_sha256: result.source_sha256.clone(),
724 status: result.status,
725 error: result.error,
726 execution_time_ms: result.execution_time_ms,
727 }
728 }
729
730 fn retained_bytes(&self) -> usize {
731 256usize
732 .saturating_add(self.script_id.capacity())
733 .saturating_add(self.script_version.capacity())
734 .saturating_add(self.source_sha256.capacity())
735 }
736}
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739pub enum LuaRegistryError {
740 InvalidConfig(LuaConfigError),
741 DuplicateId,
742 DuplicateName,
743 ScriptCapacity,
744 SourceLimit,
745 TotalSourceCapacity,
746 ScriptNotFound,
747 ScriptInUse,
748 InvocationLimit,
749 RegistrationGenerationExhausted,
750 HistorySequenceExhausted,
751 StateUnavailable,
752}
753
754impl fmt::Display for LuaRegistryError {
755 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
756 formatter.write_str(match self {
757 Self::InvalidConfig(_) => "invalid Lua registry configuration",
758 Self::DuplicateId => "Lua script ID is already registered",
759 Self::DuplicateName => "Lua script name is already registered",
760 Self::ScriptCapacity => "Lua script registry capacity reached",
761 Self::SourceLimit => "Lua script exceeds this registry source limit",
762 Self::TotalSourceCapacity => "Lua registry source-byte capacity reached",
763 Self::ScriptNotFound => "Lua script not found",
764 Self::ScriptInUse => "Lua script has an active invocation",
765 Self::InvocationLimit => "Lua script invocation counter exhausted",
766 Self::RegistrationGenerationExhausted => "Lua registry generation sequence exhausted",
767 Self::HistorySequenceExhausted => "Lua history sequence exhausted",
768 Self::StateUnavailable => "Lua registry state unavailable",
769 })
770 }
771}
772
773impl std::error::Error for LuaRegistryError {
774 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
775 match self {
776 Self::InvalidConfig(error) => Some(error),
777 _ => None,
778 }
779 }
780}
781
782pub struct LuaScriptRegistry {
783 state: Arc<Mutex<RegistryState>>,
784 config: LuaEngineConfig,
785 execution_permits: Arc<Semaphore>,
786}
787
788impl fmt::Debug for LuaScriptRegistry {
789 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
790 formatter
791 .debug_struct("LuaScriptRegistry")
792 .field("config", &self.config)
793 .finish_non_exhaustive()
794 }
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800 use std::fs;
801 use tempfile::TempDir;
802
803 fn test_config() -> LuaEngineConfig {
804 let mut config = LuaEngineConfig::minimal();
805 config.default_timeout_ms = 250;
806 config.instruction_limit = 50_000;
807 config.hook_interval = 100;
808 config.max_memory_bytes = 2 * 1_024 * 1_024;
809 config.max_source_bytes = 8 * 1_024;
810 config.max_context_bytes = 8 * 1_024;
811 config.max_target_bytes = 2 * 1_024;
812 config.max_payload_bytes = 2 * 1_024;
813 config.max_parameter_key_bytes = 256;
814 config.max_parameter_value_bytes = 2 * 1_024;
815 config.max_output_bytes = 1_024;
816 config.max_return_bytes = 1_024;
817 config
818 }
819
820 fn fixture_with_config(source: &[u8], config: &LuaEngineConfig) -> (TempDir, LuaScript) {
821 let root = tempfile::tempdir().expect("temporary script root");
822 let path = root.path().join("fixture.lua");
823 fs::write(&path, source).expect("fixture source");
824 let script = LuaScript::new_safe_with_config("fixture", &path, root.path(), config)
825 .expect("registered fixture");
826 (root, script)
827 }
828
829 fn fixture(source: &str) -> (TempDir, LuaScript) {
830 fixture_with_config(source.as_bytes(), &test_config())
831 }
832
833 async fn run(source: &str, context: LuaContext) -> LuaExecutionResult {
834 let config = test_config();
835 let (_root, script) = fixture_with_config(source.as_bytes(), &config);
836 let id = script.id();
837 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
838 registry.register(script).expect("registration");
839 registry.execute(&id, context).await.expect("execution")
840 }
841
842 #[test]
843 fn script_category_text_contract_is_exhaustive() {
844 let cases = [
845 (ScriptCategory::Web, "web"),
846 (ScriptCategory::Dns, "dns"),
847 (ScriptCategory::Smb, "smb"),
848 (ScriptCategory::Ssh, "ssh"),
849 (ScriptCategory::Database, "database"),
850 ];
851
852 assert_eq!(
853 ScriptCategory::all(),
854 &[
855 ScriptCategory::Web,
856 ScriptCategory::Dns,
857 ScriptCategory::Smb,
858 ScriptCategory::Ssh,
859 ScriptCategory::Database,
860 ]
861 );
862 for (category, token) in cases {
863 assert_eq!(category.as_str(), token);
864 assert_eq!(category.to_string(), token);
865 assert_eq!(token.parse::<ScriptCategory>(), Ok(category));
866 assert_eq!(
867 token.to_ascii_uppercase().parse::<ScriptCategory>(),
868 Ok(category)
869 );
870 }
871 assert_eq!(
872 "unknown".parse::<ScriptCategory>(),
873 Err(LuaRegistrationError::InvalidCategory)
874 );
875 }
876
877 #[test]
878 fn error_text_and_source_contracts_are_exhaustive() {
879 let mut invalid_config = test_config();
880 invalid_config.history_size = 0;
881 let config_error = invalid_config.validate().expect_err("invalid config");
882
883 let registration_errors = [
884 (
885 LuaRegistrationError::InvalidConfig(config_error),
886 "invalid Lua engine configuration",
887 ),
888 (LuaRegistrationError::InvalidName, "invalid Lua script name"),
889 (
890 LuaRegistrationError::InvalidVersion,
891 "invalid Lua script version",
892 ),
893 (
894 LuaRegistrationError::InvalidCategory,
895 "invalid Lua script category",
896 ),
897 (LuaRegistrationError::InvalidPath, "invalid Lua script path"),
898 (
899 LuaRegistrationError::OutsideApprovedRoot,
900 "Lua script is outside the approved root",
901 ),
902 (
903 LuaRegistrationError::SymlinkRejected,
904 "Lua script path contains a symbolic link",
905 ),
906 (
907 LuaRegistrationError::NotRegularFile,
908 "Lua script source is not a regular file",
909 ),
910 (
911 LuaRegistrationError::SourceTooLarge,
912 "Lua script source exceeds its configured limit",
913 ),
914 (
915 LuaRegistrationError::SourceNotUtf8,
916 "Lua script source must be UTF-8 text",
917 ),
918 (
919 LuaRegistrationError::SourceChangedDuringRegistration,
920 "Lua script source changed during registration",
921 ),
922 (
923 LuaRegistrationError::SourceReadFailed,
924 "Lua script source could not be read",
925 ),
926 ];
927 for (error, message) in registration_errors {
928 assert_eq!(error.to_string(), message);
929 assert_eq!(
930 std::error::Error::source(&error).is_some(),
931 matches!(error, LuaRegistrationError::InvalidConfig(_))
932 );
933 }
934
935 let execution_errors = [
936 (LuaExecutionError::ScriptDisabled, "script is disabled"),
937 (
938 LuaExecutionError::ConcurrencyLimit,
939 "concurrent execution limit reached",
940 ),
941 (
942 LuaExecutionError::ContextTargetLimit,
943 "context target limit exceeded",
944 ),
945 (
946 LuaExecutionError::ContextPayloadLimit,
947 "context payload limit exceeded",
948 ),
949 (
950 LuaExecutionError::ContextParameterCountLimit,
951 "context parameter count limit exceeded",
952 ),
953 (
954 LuaExecutionError::ContextParameterKeyLimit,
955 "context parameter key limit exceeded",
956 ),
957 (
958 LuaExecutionError::ContextParameterValueLimit,
959 "context parameter value limit exceeded",
960 ),
961 (
962 LuaExecutionError::ContextTotalLimit,
963 "context total limit exceeded",
964 ),
965 (LuaExecutionError::Syntax, "script syntax error"),
966 (LuaExecutionError::Runtime, "script runtime error"),
967 (
968 LuaExecutionError::MemoryLimit,
969 "Lua VM memory limit exceeded",
970 ),
971 (
972 LuaExecutionError::InstructionLimit,
973 "Lua VM instruction limit exceeded",
974 ),
975 (
976 LuaExecutionError::DeadlineExceeded,
977 "Lua execution deadline exceeded",
978 ),
979 (LuaExecutionError::Cancelled, "Lua execution cancelled"),
980 (LuaExecutionError::OutputLimit, "Lua output limit exceeded"),
981 (LuaExecutionError::OutputNotUtf8, "Lua output must be UTF-8"),
982 (
983 LuaExecutionError::UnsupportedOutputType,
984 "Lua emitted an unsupported value type",
985 ),
986 (
987 LuaExecutionError::NonFiniteOutputNumber,
988 "Lua emitted a non-finite number",
989 ),
990 (
991 LuaExecutionError::ReturnLimit,
992 "Lua return value limit exceeded",
993 ),
994 (
995 LuaExecutionError::ReturnNotUtf8,
996 "Lua return string must be UTF-8",
997 ),
998 (
999 LuaExecutionError::NonFiniteReturnNumber,
1000 "Lua returned a non-finite number",
1001 ),
1002 (
1003 LuaExecutionError::UnsupportedReturnType,
1004 "Lua returned an unsupported value type",
1005 ),
1006 (
1007 LuaExecutionError::MultipleReturnValues,
1008 "Lua returned more than one value",
1009 ),
1010 (LuaExecutionError::HostFailure, "Lua host failure"),
1011 ];
1012 for (error, message) in execution_errors {
1013 assert_eq!(error.to_string(), message);
1014 }
1015
1016 let registry_errors = [
1017 (
1018 LuaRegistryError::InvalidConfig(config_error),
1019 "invalid Lua registry configuration",
1020 ),
1021 (
1022 LuaRegistryError::DuplicateId,
1023 "Lua script ID is already registered",
1024 ),
1025 (
1026 LuaRegistryError::DuplicateName,
1027 "Lua script name is already registered",
1028 ),
1029 (
1030 LuaRegistryError::ScriptCapacity,
1031 "Lua script registry capacity reached",
1032 ),
1033 (
1034 LuaRegistryError::SourceLimit,
1035 "Lua script exceeds this registry source limit",
1036 ),
1037 (
1038 LuaRegistryError::TotalSourceCapacity,
1039 "Lua registry source-byte capacity reached",
1040 ),
1041 (LuaRegistryError::ScriptNotFound, "Lua script not found"),
1042 (
1043 LuaRegistryError::ScriptInUse,
1044 "Lua script has an active invocation",
1045 ),
1046 (
1047 LuaRegistryError::InvocationLimit,
1048 "Lua script invocation counter exhausted",
1049 ),
1050 (
1051 LuaRegistryError::RegistrationGenerationExhausted,
1052 "Lua registry generation sequence exhausted",
1053 ),
1054 (
1055 LuaRegistryError::HistorySequenceExhausted,
1056 "Lua history sequence exhausted",
1057 ),
1058 (
1059 LuaRegistryError::StateUnavailable,
1060 "Lua registry state unavailable",
1061 ),
1062 ];
1063 for (error, message) in registry_errors {
1064 assert_eq!(error.to_string(), message);
1065 assert_eq!(
1066 std::error::Error::source(&error).is_some(),
1067 matches!(error, LuaRegistryError::InvalidConfig(_))
1068 );
1069 }
1070 }
1071
1072 #[tokio::test]
1073 async fn executes_registered_snapshot_and_projects_scalar_return() {
1074 let result = run(
1075 "emit(context.target); return context.parameter('mode')",
1076 LuaContext::new("fixture-target").with_parameter("mode", "safe"),
1077 )
1078 .await;
1079 assert_eq!(result.status(), LuaExecutionStatus::Completed);
1080 assert_eq!(result.output(), "fixture-target");
1081 assert_eq!(
1082 result.return_value(),
1083 Some(&LuaReturnValue::String("safe".to_owned()))
1084 );
1085 }
1086
1087 #[tokio::test]
1088 async fn supported_scalar_return_domain_is_exact() {
1089 let boolean = run("return true", LuaContext::new("target")).await;
1090 assert_eq!(boolean.return_value(), Some(&LuaReturnValue::Boolean(true)));
1091 let integer = run("return 42", LuaContext::new("target")).await;
1092 assert_eq!(integer.return_value(), Some(&LuaReturnValue::Integer(42)));
1093 let number = run("return 1.5", LuaContext::new("target")).await;
1094 assert_eq!(number.return_value(), Some(&LuaReturnValue::Number(1.5)));
1095 let nil = run("return nil", LuaContext::new("target")).await;
1096 assert_eq!(nil.return_value(), None);
1097 let table = run("return {}", LuaContext::new("target")).await;
1098 assert_eq!(
1099 table.error(),
1100 Some(LuaExecutionError::UnsupportedReturnType)
1101 );
1102 let non_finite = run("return 0 / 0", LuaContext::new("target")).await;
1103 assert_eq!(
1104 non_finite.error(),
1105 Some(LuaExecutionError::NonFiniteReturnNumber)
1106 );
1107 let multiple = run("return true, false", LuaContext::new("target")).await;
1108 assert_eq!(
1109 multiple.error(),
1110 Some(LuaExecutionError::MultipleReturnValues)
1111 );
1112 }
1113
1114 #[tokio::test]
1115 async fn syntax_and_runtime_errors_are_typed_and_sanitized() {
1116 let syntax = run("local =", LuaContext::new("secret-target")).await;
1117 assert_eq!(syntax.error(), Some(LuaExecutionError::Syntax));
1118 assert!(!format!("{syntax:?}").contains("secret-target"));
1119
1120 let runtime = run(
1121 "local missing = nil; return missing.field",
1122 LuaContext::new("secret-target"),
1123 )
1124 .await;
1125 assert_eq!(runtime.error(), Some(LuaExecutionError::Runtime));
1126 assert!(!format!("{runtime:?}").contains("secret-target"));
1127
1128 let forged_abort = run(
1129 "return context['venom:cancelled']()",
1130 LuaContext::new("target"),
1131 )
1132 .await;
1133 assert_eq!(forged_abort.error(), Some(LuaExecutionError::Runtime));
1134 assert_eq!(forged_abort.status(), LuaExecutionStatus::Failed);
1135 }
1136
1137 #[tokio::test]
1138 async fn result_and_context_debug_redact_sensitive_values() {
1139 let context = LuaContext::new("target-secret")
1140 .with_payload("payload-secret")
1141 .with_parameter("token", "parameter-secret");
1142 let context_debug = format!("{context:?}");
1143 for secret in ["target-secret", "payload-secret", "parameter-secret"] {
1144 assert!(!context_debug.contains(secret));
1145 }
1146
1147 let result = run("emit(context.target); return context.payload", context).await;
1148 let result_debug = format!("{result:?}");
1149 for secret in ["target-secret", "payload-secret"] {
1150 assert!(!result_debug.contains(secret));
1151 }
1152 assert_eq!(
1153 format!("{:?}", LuaReturnValue::Boolean(true)),
1154 "Boolean(<redacted>)"
1155 );
1156 assert_eq!(
1157 format!("{:?}", LuaReturnValue::Integer(42)),
1158 "Integer(<redacted>)"
1159 );
1160 assert_eq!(
1161 format!("{:?}", LuaReturnValue::Number(1.5)),
1162 "Number(<redacted>)"
1163 );
1164 }
1165
1166 #[tokio::test]
1167 async fn infinite_loop_is_actually_interrupted_by_instruction_hook() {
1168 let result = run("while true do end", LuaContext::new("target")).await;
1169 assert_eq!(result.error(), Some(LuaExecutionError::InstructionLimit));
1170 assert_eq!(result.status(), LuaExecutionStatus::TimedOut);
1171 }
1172
1173 #[test]
1174 fn non_divisible_instruction_quantum_stops_before_the_next_over_limit_hook() {
1175 assert_eq!(instruction_quantum_status(0, 100, 250), (100, false));
1176 let (aborted_at, exhausted) = instruction_quantum_status(100, 100, 250);
1177 assert_eq!(aborted_at, 200);
1178 assert!(exhausted);
1179 assert!(aborted_at <= 250);
1180
1181 assert_eq!(instruction_quantum_status(0, 100, 200), (100, false));
1182 assert_eq!(instruction_quantum_status(100, 100, 200), (200, true));
1183 assert_eq!(
1184 instruction_quantum_status(u64::MAX - 50, 100, u64::MAX),
1185 (u64::MAX, true)
1186 );
1187 assert_eq!(
1188 instruction_quantum_status(u64::MAX - 150, 100, u64::MAX),
1189 (u64::MAX - 50, true)
1190 );
1191 }
1192
1193 #[tokio::test]
1194 async fn non_divisible_instruction_ceiling_allows_finite_and_interrupts_over_ceiling_code() {
1195 let mut config = test_config();
1196 config.instruction_limit = 250;
1197 config.hook_interval = 100;
1198 let (_finite_root, finite) =
1199 fixture_with_config(b"local value = 1; value = value + 1; return value", &config);
1200 let finite_id = finite.id();
1201 let finite_registry = LuaScriptRegistry::from_config(&config).expect("finite registry");
1202 finite_registry
1203 .register(finite)
1204 .expect("finite registration");
1205 let completed = finite_registry
1206 .execute(&finite_id, LuaContext::new("target"))
1207 .await
1208 .expect("finite execution");
1209 assert_eq!(completed.status(), LuaExecutionStatus::Completed);
1210 assert_eq!(completed.return_value(), Some(&LuaReturnValue::Integer(2)));
1211
1212 let (_infinite_root, infinite) = fixture_with_config(b"while true do end", &config);
1213 let infinite_id = infinite.id();
1214 let infinite_registry = LuaScriptRegistry::from_config(&config).expect("infinite registry");
1215 infinite_registry
1216 .register(infinite)
1217 .expect("infinite registration");
1218 let interrupted = infinite_registry
1219 .execute(&infinite_id, LuaContext::new("target"))
1220 .await
1221 .expect("infinite execution");
1222 assert_eq!(
1223 interrupted.error(),
1224 Some(LuaExecutionError::InstructionLimit)
1225 );
1226 }
1227
1228 #[tokio::test]
1229 async fn monotonic_deadline_is_distinct_from_instruction_limit() {
1230 let mut config = test_config();
1231 config.default_timeout_ms = 1;
1232 config.instruction_limit = 100_000_000;
1233 config.hook_interval = 100;
1234 let (_root, script) = fixture_with_config(b"while true do end", &config);
1235 let id = script.id();
1236 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1237 registry.register(script).expect("register");
1238 let result = registry
1239 .execute(&id, LuaContext::new("target"))
1240 .await
1241 .expect("execute");
1242 assert_eq!(result.error(), Some(LuaExecutionError::DeadlineExceeded));
1243 }
1244
1245 #[tokio::test]
1246 async fn cancellation_interrupts_running_lua_on_a_blocking_worker() {
1247 let mut config = test_config();
1248 config.default_timeout_ms = 5_000;
1249 config.instruction_limit = 100_000_000;
1250 config.hook_interval = 100;
1251 let (_root, script) = fixture_with_config(b"while true do end", &config);
1252 let id = script.id();
1253 let registry = Arc::new(LuaScriptRegistry::from_config(&config).expect("registry"));
1254 registry.register(script).expect("register");
1255 let cancellation = LuaCancellationToken::new();
1256 let worker_registry = Arc::clone(®istry);
1257 let worker_id = id.clone();
1258 let worker_cancellation = cancellation.clone();
1259 let worker = tokio::spawn(async move {
1260 worker_registry
1261 .execute_with_cancellation(
1262 &worker_id,
1263 LuaContext::new("target"),
1264 worker_cancellation,
1265 )
1266 .await
1267 });
1268 tokio::task::yield_now().await;
1269 cancellation.cancel();
1270 let result = worker.await.expect("worker").expect("execute");
1271 assert_eq!(result.error(), Some(LuaExecutionError::Cancelled));
1272 }
1273
1274 #[test]
1275 fn single_concurrency_permit_rejects_second_vm_and_is_reusable() {
1276 let mut config = test_config();
1277 config.max_concurrent_executions = 1;
1278 config.default_timeout_ms = 5_000;
1279 config.instruction_limit = 100_000_000;
1280 config.hook_interval = 10_000;
1281 let root = tempfile::tempdir().expect("root");
1282 let holding_path = root.path().join("holding.lua");
1283 let finite_path = root.path().join("finite.lua");
1284 fs::write(&holding_path, "while true do end").expect("holding source");
1285 fs::write(&finite_path, "return true").expect("finite source");
1286 let holding =
1287 LuaScript::new_safe_with_config("holding", &holding_path, root.path(), &config)
1288 .expect("holding script");
1289 let finite = LuaScript::new_safe_with_config("finite", &finite_path, root.path(), &config)
1290 .expect("finite script");
1291 let holding_id = holding.id();
1292 let finite_id = finite.id();
1293 let registry = Arc::new(LuaScriptRegistry::from_config(&config).expect("registry"));
1294 registry.register(holding).expect("holding registration");
1295 registry.register(finite).expect("finite registration");
1296
1297 let blocking_gate = Arc::new((Mutex::new((false, false)), std::sync::Condvar::new()));
1298 let runtime = tokio::runtime::Builder::new_multi_thread()
1299 .worker_threads(1)
1300 .max_blocking_threads(1)
1301 .enable_all()
1302 .build()
1303 .expect("test runtime");
1304 let worker_gate = Arc::clone(&blocking_gate);
1305 let blocking_worker = runtime.spawn_blocking(move || {
1306 let (state, condition) = &*worker_gate;
1307 let mut state = state.lock().expect("blocking gate");
1308 state.0 = true;
1309 condition.notify_all();
1310 while !state.1 {
1311 state = condition.wait(state).expect("blocking gate");
1312 }
1313 });
1314 {
1315 let (state, condition) = &*blocking_gate;
1316 let mut state = state.lock().expect("blocking gate");
1317 while !state.0 {
1318 state = condition.wait(state).expect("blocking gate");
1319 }
1320 }
1321
1322 runtime.block_on(async {
1323 let cancellation = LuaCancellationToken::new();
1324 let worker_registry = Arc::clone(®istry);
1325 let worker_id = holding_id.clone();
1326 let worker_cancellation = cancellation.clone();
1327 let worker = tokio::spawn(async move {
1328 worker_registry
1329 .execute_with_cancellation(
1330 &worker_id,
1331 LuaContext::new("holding"),
1332 worker_cancellation,
1333 )
1334 .await
1335 });
1336 tokio::time::timeout(Duration::from_secs(5), async {
1337 loop {
1338 let active = registry
1339 .state
1340 .lock()
1341 .expect("registry state")
1342 .scripts
1343 .get(&holding_id)
1344 .expect("holding script")
1345 .active_invocations;
1346 if active == 1 {
1347 break;
1348 }
1349 tokio::task::yield_now().await;
1350 }
1351 })
1352 .await
1353 .expect("holding invocation acquired its lease");
1354
1355 let rejected = registry
1356 .execute(&finite_id, LuaContext::new("second"))
1357 .await
1358 .expect("typed concurrency rejection");
1359 assert_eq!(rejected.error(), Some(LuaExecutionError::ConcurrencyLimit));
1360 assert_eq!(
1361 registry
1362 .state
1363 .lock()
1364 .expect("registry state")
1365 .scripts
1366 .get(&finite_id)
1367 .expect("finite script")
1368 .active_invocations,
1369 0
1370 );
1371
1372 cancellation.cancel();
1373 {
1374 let (state, condition) = &*blocking_gate;
1375 let mut state = state.lock().expect("blocking gate");
1376 state.1 = true;
1377 condition.notify_all();
1378 }
1379 blocking_worker.await.expect("blocking worker");
1380 let cancelled = worker.await.expect("worker").expect("holding execution");
1381 assert_eq!(cancelled.error(), Some(LuaExecutionError::Cancelled));
1382 assert_eq!(
1383 registry
1384 .state
1385 .lock()
1386 .expect("registry state")
1387 .scripts
1388 .get(&holding_id)
1389 .expect("holding script")
1390 .active_invocations,
1391 0
1392 );
1393
1394 let reused = registry
1395 .execute(&finite_id, LuaContext::new("reused"))
1396 .await
1397 .expect("reused permit");
1398 assert_eq!(reused.status(), LuaExecutionStatus::Completed);
1399 assert_eq!(reused.return_value(), Some(&LuaReturnValue::Boolean(true)));
1400 });
1401 }
1402
1403 #[test]
1404 fn blocking_worker_preflight_checks_cancel_and_queue_deadline_before_vm_creation() {
1405 let config = test_config();
1406 let (_root, script) = fixture_with_config(b"return true", &config);
1407 let stale_start = Instant::now()
1408 .checked_sub(Duration::from_millis(config.default_timeout_ms + 1))
1409 .expect("stale start");
1410 let expired = execute_snapshot(
1411 script.clone(),
1412 LuaContext::new("target"),
1413 config.clone(),
1414 LuaCancellationToken::new(),
1415 stale_start,
1416 );
1417 assert_eq!(expired.error(), Some(LuaExecutionError::DeadlineExceeded));
1418
1419 let cancellation = LuaCancellationToken::new();
1420 cancellation.cancel();
1421 let cancelled = execute_snapshot(
1422 script,
1423 LuaContext::new("target"),
1424 config,
1425 cancellation,
1426 stale_start,
1427 );
1428 assert_eq!(cancelled.error(), Some(LuaExecutionError::Cancelled));
1429 }
1430
1431 #[tokio::test]
1432 async fn pcall_coroutines_and_all_standard_libraries_are_absent() {
1433 let result = run(
1434 "return pcall == nil and xpcall == nil and coroutine == nil and os == nil and io == nil and debug == nil and package == nil and load == nil and dofile == nil and loadfile == nil and require == nil and print == nil and warn == nil and collectgarbage == nil and math == nil and string == nil and table == nil and utf8 == nil",
1435 LuaContext::new("target"),
1436 )
1437 .await;
1438 assert_eq!(result.return_value(), Some(&LuaReturnValue::Boolean(true)));
1439
1440 let method = run("return ('x'):match('x')", LuaContext::new("target")).await;
1441 assert_eq!(method.error(), Some(LuaExecutionError::Runtime));
1442 }
1443
1444 #[tokio::test]
1445 async fn memory_bomb_fails_with_fixed_memory_code() {
1446 let result = run(
1447 "local value = 'xxxxxxxx'; while true do value = value .. value end",
1448 LuaContext::new("target"),
1449 )
1450 .await;
1451 assert_eq!(result.error(), Some(LuaExecutionError::MemoryLimit));
1452 }
1453
1454 #[tokio::test]
1455 async fn context_is_immutable_and_parameter_order_is_stable() {
1456 let result = run(
1457 "context.target = 'changed'; return context.target",
1458 LuaContext::new("original"),
1459 )
1460 .await;
1461 assert_eq!(result.error(), Some(LuaExecutionError::Runtime));
1462
1463 let result = run(
1464 "local a, av = context.parameter_at(1); local b, bv = context.parameter_at(2); emit(a .. '=' .. av .. ',' .. b .. '=' .. bv)",
1465 LuaContext::new("target")
1466 .with_parameter("z", "last")
1467 .with_parameter("a", "first"),
1468 )
1469 .await;
1470 assert_eq!(result.output(), "a=first,z=last");
1471 }
1472
1473 #[tokio::test]
1474 async fn output_cap_enforces_exact_boundary_plus_one() {
1475 let mut config = test_config();
1476 config.max_output_bytes = 4;
1477 let root = tempfile::tempdir().expect("root");
1478 let exact_path = root.path().join("exact.lua");
1479 fs::write(&exact_path, "emit('1234')").expect("source");
1480 let exact_script =
1481 LuaScript::new_safe_with_config("exact", &exact_path, root.path(), &config)
1482 .expect("script");
1483 let exact_id = exact_script.id();
1484 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1485 registry.register(exact_script).expect("register");
1486 let exact = registry
1487 .execute(&exact_id, LuaContext::new("target"))
1488 .await
1489 .expect("execute");
1490 assert!(exact.success());
1491 assert_eq!(exact.output(), "1234");
1492
1493 let plus_path = root.path().join("plus.lua");
1494 fs::write(&plus_path, "emit('12345')").expect("source");
1495 let plus_script = LuaScript::new_safe_with_config("plus", &plus_path, root.path(), &config)
1496 .expect("script");
1497 let plus_id = plus_script.id();
1498 registry.register(plus_script).expect("register");
1499 let plus = registry
1500 .execute(&plus_id, LuaContext::new("target"))
1501 .await
1502 .expect("execute");
1503 assert_eq!(plus.error(), Some(LuaExecutionError::OutputLimit));
1504 assert!(plus.output().is_empty());
1505 }
1506
1507 #[tokio::test]
1508 async fn oversized_vm_string_is_rejected_before_any_rust_heap_clone() {
1509 let mut config = test_config();
1510 config.max_output_bytes = 4;
1511 let (_root, script) = fixture_with_config(
1512 b"local value = '12345678'; for _ = 1, 8 do value = value .. value end; emit(value)",
1513 &config,
1514 );
1515 let id = script.id();
1516 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1517 registry.register(script).expect("register");
1518 let result = registry
1519 .execute(&id, LuaContext::new("target"))
1520 .await
1521 .expect("execute");
1522 assert_eq!(result.error(), Some(LuaExecutionError::OutputLimit));
1523 assert!(result.output().is_empty());
1524 }
1525
1526 #[tokio::test]
1527 async fn unsupported_nonfinite_and_non_utf8_output_are_distinct() {
1528 let unsupported = run("emit({})", LuaContext::new("target")).await;
1529 assert_eq!(
1530 unsupported.error(),
1531 Some(LuaExecutionError::UnsupportedOutputType)
1532 );
1533
1534 let nonfinite = run("emit(0 / 0)", LuaContext::new("target")).await;
1535 assert_eq!(
1536 nonfinite.error(),
1537 Some(LuaExecutionError::NonFiniteOutputNumber)
1538 );
1539
1540 let invalid_utf8 = run("emit('\\255')", LuaContext::new("target")).await;
1541 assert_eq!(invalid_utf8.error(), Some(LuaExecutionError::OutputNotUtf8));
1542 }
1543
1544 #[tokio::test]
1545 async fn return_cap_enforces_exact_boundary_plus_one() {
1546 let mut config = test_config();
1547 config.max_return_bytes = 4;
1548 let root = tempfile::tempdir().expect("root");
1549 let exact_path = root.path().join("exact.lua");
1550 fs::write(&exact_path, "return '1234'").expect("source");
1551 let exact_script =
1552 LuaScript::new_safe_with_config("exact", &exact_path, root.path(), &config)
1553 .expect("script");
1554 let exact_id = exact_script.id();
1555 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1556 registry.register(exact_script).expect("register");
1557 let exact = registry
1558 .execute(&exact_id, LuaContext::new("target"))
1559 .await
1560 .expect("execute");
1561 assert_eq!(
1562 exact.return_value(),
1563 Some(&LuaReturnValue::String("1234".to_owned()))
1564 );
1565
1566 let plus_path = root.path().join("plus.lua");
1567 fs::write(&plus_path, "return '12345'").expect("source");
1568 let plus_script = LuaScript::new_safe_with_config("plus", &plus_path, root.path(), &config)
1569 .expect("script");
1570 let plus_id = plus_script.id();
1571 registry.register(plus_script).expect("register");
1572 let plus = registry
1573 .execute(&plus_id, LuaContext::new("target"))
1574 .await
1575 .expect("execute");
1576 assert_eq!(plus.error(), Some(LuaExecutionError::ReturnLimit));
1577 }
1578
1579 #[tokio::test]
1580 async fn each_context_limit_is_checked_before_vm_creation() {
1581 let mut config = test_config();
1582 config.max_target_bytes = 4;
1583 config.max_payload_bytes = 4;
1584 config.max_parameters = 1;
1585 config.max_parameter_key_bytes = 4;
1586 config.max_parameter_value_bytes = 4;
1587 config.max_context_bytes = 8;
1588 let (_root, script) = fixture_with_config(b"return true", &config);
1589 let id = script.id();
1590 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1591 registry.register(script).expect("register");
1592
1593 for (context, expected) in [
1594 (
1595 LuaContext::new("12345"),
1596 LuaExecutionError::ContextTargetLimit,
1597 ),
1598 (
1599 LuaContext::new("").with_payload("12345"),
1600 LuaExecutionError::ContextPayloadLimit,
1601 ),
1602 (
1603 LuaContext::new("")
1604 .with_parameter("a", "")
1605 .with_parameter("b", ""),
1606 LuaExecutionError::ContextParameterCountLimit,
1607 ),
1608 (
1609 LuaContext::new("").with_parameter("12345", ""),
1610 LuaExecutionError::ContextParameterKeyLimit,
1611 ),
1612 (
1613 LuaContext::new("").with_parameter("a", "12345"),
1614 LuaExecutionError::ContextParameterValueLimit,
1615 ),
1616 (
1617 LuaContext::new("1234")
1618 .with_payload("1234")
1619 .with_parameter("a", ""),
1620 LuaExecutionError::ContextTotalLimit,
1621 ),
1622 ] {
1623 let result = registry.execute(&id, context).await.expect("execute");
1624 assert_eq!(result.error(), Some(expected));
1625 assert_eq!(result.status(), LuaExecutionStatus::Rejected);
1626 }
1627 }
1628
1629 #[tokio::test]
1630 async fn source_snapshot_survives_later_file_replacement() {
1631 let config = test_config();
1632 let root = tempfile::tempdir().expect("root");
1633 let path = root.path().join("fixture.lua");
1634 fs::write(&path, "return 'original'").expect("source");
1635 let script = LuaScript::new_safe_with_config("snapshot", &path, root.path(), &config)
1636 .expect("script");
1637 fs::write(&path, "return 'replacement'").expect("replacement");
1638 let id = script.id();
1639 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1640 registry.register(script).expect("register");
1641 let result = registry
1642 .execute(&id, LuaContext::new("target"))
1643 .await
1644 .expect("execute");
1645 assert_eq!(
1646 result.return_value(),
1647 Some(&LuaReturnValue::String("original".to_owned()))
1648 );
1649 }
1650
1651 #[test]
1652 fn source_size_enforces_exact_boundary_plus_one() {
1653 let mut config = test_config();
1654 config.max_source_bytes = 4;
1655 let root = tempfile::tempdir().expect("root");
1656 let exact = root.path().join("exact.lua");
1657 fs::write(&exact, "true").expect("source");
1658 assert!(LuaScript::new_safe_with_config("exact", &exact, root.path(), &config).is_ok());
1659 let plus = root.path().join("plus.lua");
1660 fs::write(&plus, "false").expect("source");
1661 assert_eq!(
1662 LuaScript::new_safe_with_config("plus", &plus, root.path(), &config).unwrap_err(),
1663 LuaRegistrationError::SourceTooLarge
1664 );
1665 }
1666
1667 #[test]
1668 fn traversal_and_non_lua_paths_are_rejected() {
1669 let config = test_config();
1670 let root = tempfile::tempdir().expect("root");
1671 let outside = tempfile::tempdir().expect("outside");
1672 let outside_path = outside.path().join("outside.lua");
1673 fs::write(&outside_path, "return true").expect("source");
1674 assert_eq!(
1675 LuaScript::new_safe_with_config("escape", &outside_path, root.path(), &config)
1676 .unwrap_err(),
1677 LuaRegistrationError::OutsideApprovedRoot
1678 );
1679 let text = root.path().join("source.txt");
1680 fs::write(&text, "return true").expect("source");
1681 assert_eq!(
1682 LuaScript::new_safe_with_config("text", &text, root.path(), &config).unwrap_err(),
1683 LuaRegistrationError::InvalidPath
1684 );
1685 }
1686
1687 #[cfg(unix)]
1688 #[test]
1689 fn symlink_source_is_rejected() {
1690 use std::os::unix::fs::symlink;
1691 let config = test_config();
1692 let root = tempfile::tempdir().expect("root");
1693 let source = root.path().join("source.lua");
1694 let link = root.path().join("link.lua");
1695 fs::write(&source, "return true").expect("source");
1696 symlink(&source, &link).expect("symlink");
1697 assert_eq!(
1698 LuaScript::new_safe_with_config("link", &link, root.path(), &config).unwrap_err(),
1699 LuaRegistrationError::SymlinkRejected
1700 );
1701 }
1702
1703 #[cfg(unix)]
1704 #[test]
1705 fn intermediate_directory_symlink_component_is_rejected() {
1706 use std::os::unix::fs::symlink;
1707 let config = test_config();
1708 let root = tempfile::tempdir().expect("root");
1709 let actual = root.path().join("actual");
1710 let linked = root.path().join("linked");
1711 fs::create_dir(&actual).expect("actual directory");
1712 fs::write(actual.join("source.lua"), "return true").expect("source");
1713 symlink(&actual, &linked).expect("directory symlink");
1714 assert_eq!(
1715 LuaScript::new_safe_with_config(
1716 "linked-directory",
1717 linked.join("source.lua"),
1718 root.path(),
1719 &config,
1720 )
1721 .unwrap_err(),
1722 LuaRegistrationError::SymlinkRejected
1723 );
1724 }
1725
1726 #[cfg(windows)]
1727 #[test]
1728 fn windows_symlink_source_is_rejected_when_host_can_create_it() {
1729 use std::os::windows::fs::symlink_file;
1730 let config = test_config();
1731 let root = tempfile::tempdir().expect("root");
1732 let source = root.path().join("source.lua");
1733 let link = root.path().join("link.lua");
1734 fs::write(&source, "return true").expect("source");
1735 if symlink_file(&source, &link).is_ok() {
1736 assert_eq!(
1737 LuaScript::new_safe_with_config("link", &link, root.path(), &config).unwrap_err(),
1738 LuaRegistrationError::SymlinkRejected
1739 );
1740 }
1741 }
1742
1743 #[cfg(windows)]
1744 #[test]
1745 fn windows_intermediate_directory_symlink_is_rejected_when_host_can_create_it() {
1746 use std::os::windows::fs::symlink_dir;
1747 let config = test_config();
1748 let root = tempfile::tempdir().expect("root");
1749 let actual = root.path().join("actual");
1750 let linked = root.path().join("linked");
1751 fs::create_dir(&actual).expect("actual directory");
1752 fs::write(actual.join("source.lua"), "return true").expect("source");
1753 if symlink_dir(&actual, &linked).is_ok() {
1754 assert_eq!(
1755 LuaScript::new_safe_with_config(
1756 "linked-directory",
1757 linked.join("source.lua"),
1758 root.path(),
1759 &config,
1760 )
1761 .unwrap_err(),
1762 LuaRegistrationError::SymlinkRejected
1763 );
1764 }
1765 }
1766
1767 #[tokio::test]
1768 async fn text_only_mode_rejects_lua_binary_signature() {
1769 let config = test_config();
1770 let (_root, script) = fixture_with_config(b"Lua", &config);
1771 let id = script.id();
1772 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1773 registry.register(script).expect("register");
1774 let result = registry
1775 .execute(&id, LuaContext::new("target"))
1776 .await
1777 .expect("execute");
1778 assert_eq!(result.error(), Some(LuaExecutionError::Syntax));
1779 }
1780
1781 #[tokio::test]
1782 async fn disabled_and_pre_cancelled_scripts_fail_before_vm_execution() {
1783 let config = test_config();
1784 let (_root, script) = fixture("return true");
1785 let id = script.id();
1786 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1787 registry.register(script).expect("register");
1788 registry.set_enabled(&id, false).expect("disable");
1789 let disabled = registry
1790 .execute(&id, LuaContext::new("target"))
1791 .await
1792 .expect("execute");
1793 assert_eq!(disabled.error(), Some(LuaExecutionError::ScriptDisabled));
1794
1795 registry.set_enabled(&id, true).expect("enable");
1796 let cancellation = LuaCancellationToken::new();
1797 cancellation.cancel();
1798 let cancelled = registry
1799 .execute_with_cancellation(&id, LuaContext::new("target"), cancellation)
1800 .await
1801 .expect("execute");
1802 assert_eq!(cancelled.error(), Some(LuaExecutionError::Cancelled));
1803 }
1804
1805 #[test]
1806 fn execution_without_tokio_runtime_is_typed_and_releases_registry_state() {
1807 let config = test_config();
1808 let (_root, script) = fixture_with_config(b"return true", &config);
1809 let id = script.id();
1810 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1811 registry.register(script).expect("register");
1812
1813 let mut context = std::task::Context::from_waker(std::task::Waker::noop());
1814 let execution = registry.execute(&id, LuaContext::new("target"));
1815 let mut execution = std::pin::pin!(execution);
1816 let result = match std::future::Future::poll(execution.as_mut(), &mut context) {
1817 Poll::Ready(result) => result.expect("typed execution result"),
1818 Poll::Pending => panic!("no-runtime preflight must not suspend"),
1819 };
1820
1821 assert_eq!(result.error(), Some(LuaExecutionError::HostFailure));
1822 assert_eq!(registry.get_history(&id).expect("history").len(), 1);
1823 registry
1824 .unregister(&id)
1825 .expect("no invocation lease was retained");
1826 }
1827
1828 #[tokio::test]
1829 async fn history_is_entry_and_byte_bounded_and_newest_ordered() {
1830 let mut config = test_config();
1831 config.history_size = 2;
1832 config.max_history_bytes_per_script = 1_024;
1833 config.max_history_bytes_total = 1_024;
1834 let (_root, script) = fixture_with_config(b"return context.target", &config);
1835 let id = script.id();
1836 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1837 registry.register(script).expect("register");
1838 registry
1839 .execute(&id, LuaContext::new("one"))
1840 .await
1841 .expect("execute");
1842 registry.set_enabled(&id, false).expect("disable");
1843 registry
1844 .execute(&id, LuaContext::new("two"))
1845 .await
1846 .expect("execute");
1847 registry.set_enabled(&id, true).expect("enable");
1848 registry
1849 .execute(&id, LuaContext::new("three"))
1850 .await
1851 .expect("execute");
1852 let history = registry.get_history(&id).expect("history");
1853 assert_eq!(history.len(), 2);
1854 assert_eq!(history[0].status(), LuaExecutionStatus::Rejected);
1855 assert_eq!(history[0].error(), Some(LuaExecutionError::ScriptDisabled));
1856 assert_eq!(history[1].status(), LuaExecutionStatus::Completed);
1857 let recent = registry.get_recent_history(&id, 1).expect("recent");
1858 assert_eq!(recent[0].status(), LuaExecutionStatus::Completed);
1859 }
1860
1861 #[tokio::test]
1862 async fn history_receipts_never_retain_output_return_or_context() {
1863 let config = test_config();
1864 let (_root, script) =
1865 fixture_with_config(b"emit(context.target); return context.payload", &config);
1866 let id = script.id();
1867 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1868 registry.register(script).expect("register");
1869 let result = registry
1870 .execute(
1871 &id,
1872 LuaContext::new("output-secret").with_payload("return-secret"),
1873 )
1874 .await
1875 .expect("execute");
1876 assert_eq!(result.output(), "output-secret");
1877 let history = registry.get_history(&id).expect("history");
1878 let wire = serde_json::to_string(&history).expect("serialize receipt");
1879 assert!(!wire.contains("output-secret"));
1880 assert!(!wire.contains("return-secret"));
1881 assert_eq!(history[0].source_sha256(), result.source_sha256());
1882 assert_eq!(history[0].script_version(), result.script_version());
1883 }
1884
1885 #[tokio::test]
1886 async fn global_history_byte_cap_evicts_the_stable_oldest_receipt() {
1887 let mut config = test_config();
1888 config.max_history_bytes_per_script = 512;
1889 config.max_history_bytes_total = 512;
1890 let root = tempfile::tempdir().expect("root");
1891 let first_path = root.path().join("first.lua");
1892 let second_path = root.path().join("second.lua");
1893 fs::write(&first_path, "return true").expect("first source");
1894 fs::write(&second_path, "return false").expect("second source");
1895 let first = LuaScript::new_safe_with_config("first", &first_path, root.path(), &config)
1896 .expect("first");
1897 let second = LuaScript::new_safe_with_config("second", &second_path, root.path(), &config)
1898 .expect("second");
1899 let first_id = first.id();
1900 let second_id = second.id();
1901 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1902 registry.register(first).expect("first registration");
1903 registry.register(second).expect("second registration");
1904
1905 registry
1906 .execute(&first_id, LuaContext::new("first"))
1907 .await
1908 .expect("first execution");
1909 registry
1910 .execute(&second_id, LuaContext::new("second"))
1911 .await
1912 .expect("second execution");
1913
1914 assert!(registry
1915 .get_history(&first_id)
1916 .expect("first history")
1917 .is_empty());
1918 assert_eq!(
1919 registry
1920 .get_history(&second_id)
1921 .expect("second history")
1922 .len(),
1923 1
1924 );
1925 }
1926
1927 #[tokio::test]
1928 async fn disabling_affects_new_calls_and_unregister_rejects_active_invocation() {
1929 let mut config = test_config();
1930 config.default_timeout_ms = 5_000;
1931 config.instruction_limit = 100_000_000;
1932 config.hook_interval = 100;
1933 let (_root, script) = fixture_with_config(b"while true do end", &config);
1934 let retained = script.clone();
1935 let id = script.id();
1936 let registry = Arc::new(LuaScriptRegistry::from_config(&config).expect("registry"));
1937 registry.register(script).expect("register");
1938 let cancellation = LuaCancellationToken::new();
1939 let worker_registry = Arc::clone(®istry);
1940 let worker_id = id.clone();
1941 let worker_cancellation = cancellation.clone();
1942 let worker = tokio::spawn(async move {
1943 worker_registry
1944 .execute_with_cancellation(
1945 &worker_id,
1946 LuaContext::new("target"),
1947 worker_cancellation,
1948 )
1949 .await
1950 });
1951 tokio::time::sleep(Duration::from_millis(10)).await;
1952 assert_eq!(registry.unregister(&id), Err(LuaRegistryError::ScriptInUse));
1953 registry.set_enabled(&id, false).expect("disable");
1954 let rejected = registry
1955 .execute(&id, LuaContext::new("target"))
1956 .await
1957 .expect("new invocation");
1958 assert_eq!(rejected.error(), Some(LuaExecutionError::ScriptDisabled));
1959 cancellation.cancel();
1960 let active = worker.await.expect("worker").expect("active invocation");
1961 assert_eq!(active.error(), Some(LuaExecutionError::Cancelled));
1962 registry.unregister(&id).expect("unregister after finish");
1963 registry
1964 .register(retained)
1965 .expect("same stable identity may register again");
1966 assert_eq!(registry.count(), Ok(1));
1967 }
1968
1969 #[test]
1970 fn public_script_clones_cannot_mutate_registered_enabled_state() {
1971 let (_root, script) = fixture("return true");
1972 let disabled_clone = script.clone().with_enabled(false);
1973 assert!(script.manifest().enabled());
1974 assert!(!disabled_clone.manifest().enabled());
1975 let registry = LuaScriptRegistry::from_config(&test_config()).expect("registry");
1976 registry.register(script).expect("register");
1977 assert_eq!(registry.enabled_count(), Ok(1));
1978 let _ = disabled_clone.with_enabled(true);
1979 assert_eq!(registry.enabled_count(), Ok(1));
1980 }
1981
1982 #[test]
1983 fn total_source_byte_capacity_is_accounted_and_released() {
1984 let mut config = test_config();
1985 config.max_source_bytes = 4;
1986 config.max_total_source_bytes = 6;
1987 let root = tempfile::tempdir().expect("root");
1988 let first_path = root.path().join("first.lua");
1989 let second_path = root.path().join("second.lua");
1990 fs::write(&first_path, "a=1\n").expect("source");
1991 fs::write(&second_path, "b=2\n").expect("source");
1992 let first = LuaScript::new_safe_with_config("first", &first_path, root.path(), &config)
1993 .expect("first");
1994 let second = LuaScript::new_safe_with_config("second", &second_path, root.path(), &config)
1995 .expect("second");
1996 let first_id = first.id();
1997 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
1998 registry.register(first).expect("register");
1999 assert_eq!(
2000 registry.register(second.clone()),
2001 Err(LuaRegistryError::TotalSourceCapacity)
2002 );
2003 registry.unregister(&first_id).expect("unregister");
2004 registry.register(second).expect("capacity released");
2005 }
2006
2007 #[tokio::test]
2008 async fn checked_history_sequence_overflow_does_not_mutate_history() {
2009 let config = test_config();
2010 let (_root, script) = fixture_with_config(b"return true", &config);
2011 let id = script.id();
2012 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
2013 registry.register(script).expect("register");
2014 registry
2015 .execute(&id, LuaContext::new("first"))
2016 .await
2017 .expect("first");
2018 let before = registry.get_history(&id).expect("history");
2019 registry.state.lock().expect("state").next_sequence = u64::MAX;
2020 assert!(matches!(
2021 registry.execute(&id, LuaContext::new("second")).await,
2022 Err(LuaRegistryError::HistorySequenceExhausted)
2023 ));
2024 let after = registry.get_history(&id).expect("history");
2025 assert_eq!(before, after);
2026 }
2027
2028 #[test]
2029 fn terminal_control_precedence_is_sticky_then_cancel_then_deadline() {
2030 assert_eq!(
2031 terminal_control_error(Some(StickyAbort::Output), true, true),
2032 Some(LuaExecutionError::OutputLimit)
2033 );
2034 assert_eq!(
2035 terminal_control_error(None, true, true),
2036 Some(LuaExecutionError::Cancelled)
2037 );
2038 assert_eq!(
2039 terminal_control_error(None, false, true),
2040 Some(LuaExecutionError::DeadlineExceeded)
2041 );
2042 assert_eq!(terminal_control_error(None, false, false), None);
2043 }
2044
2045 #[test]
2046 fn hook_controls_deadline_is_sticky_without_charging_instructions() {
2047 let sticky_abort = Cell::new(None);
2048 let instruction_count = Cell::new(41);
2049
2050 let error = enforce_hook_controls(&sticky_abort, &instruction_count, false, true, 10, 100)
2051 .expect_err("deadline must interrupt the hook");
2052
2053 assert!(matches!(
2054 error,
2055 MluaError::RuntimeError(message) if message == ABORT_DEADLINE
2056 ));
2057 assert_eq!(sticky_abort.get(), Some(StickyAbort::Deadline));
2058 assert_eq!(instruction_count.get(), 41);
2059 assert_eq!(
2060 terminal_control_error(sticky_abort.get(), false, false),
2061 Some(LuaExecutionError::DeadlineExceeded)
2062 );
2063 }
2064
2065 #[tokio::test]
2066 async fn fresh_vm_prevents_cross_run_global_state() {
2067 let first = run("state = 1; return state", LuaContext::new("target")).await;
2068 assert_eq!(first.error(), Some(LuaExecutionError::Runtime));
2069 let second = run("return state == nil", LuaContext::new("target")).await;
2070 assert_eq!(second.return_value(), Some(&LuaReturnValue::Boolean(true)));
2071 }
2072
2073 #[test]
2074 fn manifests_are_inert_sorted_and_hide_source_path() {
2075 let config = test_config();
2076 let root = tempfile::tempdir().expect("root");
2077 let first_path = root.path().join("first.lua");
2078 let second_path = root.path().join("second.lua");
2079 fs::write(&first_path, "return true").expect("source");
2080 fs::write(&second_path, "return false").expect("source");
2081 let first = LuaScript::new_safe_with_config("zeta", &first_path, root.path(), &config)
2082 .expect("one");
2083 let second = LuaScript::new_safe_with_config("alpha", &second_path, root.path(), &config)
2084 .expect("two");
2085 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
2086 registry.register(first).expect("register");
2087 registry.register(second).expect("register");
2088 let manifests = registry.list_all().expect("manifests");
2089 assert_eq!(manifests[0].name(), "alpha");
2090 let serialized = serde_json::to_string(&manifests).expect("serialize manifests");
2091 assert!(!serialized.contains(root.path().to_string_lossy().as_ref()));
2092 assert!(!serialized.contains("return true"));
2093 }
2094
2095 #[test]
2096 fn registry_rejects_duplicate_identity_name_and_capacity() {
2097 let mut config = test_config();
2098 config.max_scripts = 2;
2099 let (root, script) = fixture_with_config(b"return true", &config);
2100 let duplicate = script.clone();
2101 let registry = LuaScriptRegistry::from_config(&config).expect("registry");
2102 registry.register(script).expect("register");
2103 assert_eq!(
2104 registry.register(duplicate),
2105 Err(LuaRegistryError::DuplicateId)
2106 );
2107
2108 let same_name_path = root.path().join("same-name.lua");
2109 fs::write(&same_name_path, "return false").expect("source");
2110 let same_name =
2111 LuaScript::new_safe_with_config("fixture", &same_name_path, root.path(), &config)
2112 .expect("same name");
2113 assert_eq!(
2114 registry.register(same_name),
2115 Err(LuaRegistryError::DuplicateName)
2116 );
2117
2118 let second_path = root.path().join("second.lua");
2119 let third_path = root.path().join("third.lua");
2120 fs::write(&second_path, "return 2").expect("source");
2121 fs::write(&third_path, "return 3").expect("source");
2122 let second = LuaScript::new_safe_with_config("second", &second_path, root.path(), &config)
2123 .expect("second");
2124 let third = LuaScript::new_safe_with_config("third", &third_path, root.path(), &config)
2125 .expect("third");
2126 registry.register(second).expect("second registration");
2127 assert_eq!(
2128 registry.register(third),
2129 Err(LuaRegistryError::ScriptCapacity)
2130 );
2131 }
2132}