Skip to main content

termivar_scanner/
lua_engine.rs

1//! Bounded, host-owned execution for registered Lua source snapshots.
2//!
3//! This opt-in library surface is not part of either repository CLI runtime.
4//! It creates a fresh Lua 5.4 VM for every request, exposes only immutable
5//! context accessors plus bounded output, and never reopens source after
6//! registration.
7//!
8//! The VM is an in-process cooperative boundary, not process isolation. Lua
9//! instruction hooks interrupt bytecode loops, but cannot hard-preempt a defect
10//! inside the Lua VM or a long native/C operation. This host therefore loads no
11//! standard libraries and exposes no capability-bearing callbacks.
12//! Portable path validation cannot defeat a writer racing changes inside the
13//! approved tree, so that root must remain trusted and non-writable throughout
14//! registration. Later file replacement is harmless because execution uses the
15//! private registration-time snapshot.
16
17use 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/// Script categories used by inert registry metadata.
67#[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/// Fixed registration failure that never includes a path, source, or OS error.
122#[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/// Inert, serializable metadata. It cannot be deserialized into execution authority.
170#[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/// Opaque executable source created only through bounded registration.
219#[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/// Immutable host input exposed to Lua through read-only accessors.
335#[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/// Cloneable host cancellation signal checked from the VM instruction hook.
413#[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/// Scalar value projected from a completed Lua invocation.
535///
536/// The executor only constructs [`Self::Number`] for finite values. Constructing
537/// this public data enum directly does not grant script execution authority.
538#[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/// Bounded, privacy-minimized execution metadata retained by the registry.
674///
675/// Script identity, source provenance, status, error, and timing remain sensitive
676/// operational metadata. The receipt never contains Lua output, return values,
677/// context, source text, or filesystem paths.
678#[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(&registry);
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(&registry);
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(&registry);
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}