v. vanilla 2.0

restored/script.cpp

← All source files · Download this file

37,948 bytes · SHA256 ef5be53bee5b6a5b93ae43078705997fb99a46c2772f717d20bfd9b152b862de

// Linux restoration of the vScript/vFunc/CodeLine/variable model.
// All storage, parsing, validation and execution machinery below is rewritten.
#include "script.h"
#include <algorithm>
#include <charconv>
#include <cctype>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <limits>
#include <sstream>
#include <string_view>
#include <nlohmann/json.hpp>
#include <curl/curl.h>
#include <cstring>
#include <cerrno>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <linux/fs.h>
#include <sqlite3.h>
#include <mysql.h>
#include <sql.h>
#include <sqlext.h>
#include <ctime>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <poll.h>
#include <array>
#include <ares.h>
#include <openssl/ssl.h>
#include <openssl/rand.h>
#include <openssl/err.h>
#include <openssl/x509v3.h>

namespace vanilla {
namespace fs = std::filesystem;
static constexpr std::size_t none = static_cast<std::size_t>(-1);
static bool identifier(const std::string& s) {
    if(s.empty() || s.size() > 128 || !(std::isalpha(static_cast<unsigned char>(s[0])) || s[0] == '_')) return false;
    return std::all_of(s.begin(),s.end(),[](unsigned char c) { return std::isalnum(c) || c == '_'; });
}
static void nameCheck(const std::string& s,const Source& loc) {
    if(!identifier(s)) fail(loc,"invalid identifier: " + s.substr(0,128));
}
static void append(std::string& dst,const std::string& src,const Source& s) {
    if(src.size() > valueLimit || dst.size() > valueLimit-src.size()) failLimit(s,"value expansion limit (1 MiB)");
    dst += src;
}
static std::vector<Token> tokenize(const std::string& line,const Source& loc) {
    std::vector<Token> result;
    std::size_t p = 0;
    while(p < line.size()) {
        while(p < line.size() && std::isspace(static_cast<unsigned char>(line[p]))) ++p;
        if(p == line.size() || line[p] == '#') break;
        Token t;
        bool quote = false;
        while(p < line.size() && (quote || !std::isspace(static_cast<unsigned char>(line[p])))) {
            char c = line[p++];
            if(c == '"') { quote = !quote; t.literal = true; continue; }
            if(c == '\\') {
                t.literal = true;
                if(p == line.size()) fail(loc,"incomplete escape");
                c = line[p++];
                if(c == 'n') c = '\n'; else if(c == 'r') c = '\r'; else if(c == 't') c = '\t';
                else if(c != '\\' && c != '"' && c != '$' && c != '%' && c != '&' && c != '#') fail(loc,"unknown escape");
            }
            if(c == '\0') fail(loc,"NUL byte unavailable in source");
            t.text += c;
        }
        if(quote) fail(loc,"unterminated quote");
        if(result.size() >= 1024) failLimit(loc,"token count limit (1024)");
        result.push_back(std::move(t));
    }
    return result;
}
static void count(const CodeLine& c,std::size_t lo,std::size_t hi) {
    if(c.arg.size() < lo || c.arg.size() > hi) fail(c.source,"invalid argument count for " + c.arg[0].text);
}
static std::string destination(const Token& t,const Source& loc) {
    if(t.literal || t.text.size() < 2 || t.text[0] != '$') fail(loc,"expected $destination");
    auto name = t.text.substr(1); nameCheck(name,loc); return name;
}
static variable& lookup(Frame& f,const Token& t,const Source& loc) {
    auto name = destination(t,loc);
    auto it = f.vars.find(name);
    if(it == f.vars.end()) fail(loc,"undefined variable: " + name);
    return it->second;
}
static const std::string& textValue(variable& v,const Source& loc) {
    if(v.textPending) {
        auto text = format(v.numericValue,loc);
        // Match assign's fresh-copy capacity; never retain a large old buffer.
        std::string fresh(text.data(),text.size());
        v.value.swap(fresh);
        v.textPending = false;
    }
    return v.value;
}
static std::string value(Frame& f,const Token& t,const Source& loc) {
    if(!t.literal && !t.text.empty() && t.text[0] == '$') {
        auto& v = lookup(f,t,loc);
        if(v.array) fail(loc,"array used as scalar");
        if(v.structured) fail(loc,"JSON used as scalar; use json text/stringify");
        return textValue(v,loc);
    }
    if(!t.literal && !t.text.empty() && t.text[0] == '%') {
        auto digits = t.text.substr(1);
        if(digits.empty() || digits.size() > 4 || !std::all_of(digits.begin(),digits.end(),[](char c) { return c >= '0' && c <= '9'; }))
            fail(loc,"invalid argument index");
        auto index = integer(digits,0,9999,loc);
        if(index < 1 || static_cast<std::size_t>(index) > f.args.size()) fail(loc,"argument index out of bounds");
        auto& v=f.args[static_cast<std::size_t>(index)-1];
        if(v.array) fail(loc,"array used as scalar");
        if(v.structured) fail(loc,"JSON used as scalar; use named parameter");
        return textValue(v,loc);
    }
    return t.text;
}
static void assign(Frame& f,const Token& dest,const std::string& s,const Source& loc) {
    if(s.size() > valueLimit) failLimit(loc,"value size limit");
    auto& v = lookup(f,dest,loc);
    // Replace storage, not just logical size: repeated large-to-small assignments
    // must not retain an unaccounted allocation in every local variable.
    v = variable{s,{},false};
}
static double numericValue(Frame& f,const Token& t,const Source& loc) {
    if(!t.literal && !t.text.empty() && t.text[0] == '$') {
        auto& v = lookup(f,t,loc);
        if(v.array) fail(loc,"array used as scalar");
        if(!v.numericKnown) {
            if(v.structured) fail(loc,"JSON used as number; use json text");
            v.numericValue = number(textValue(v,loc),loc);
            v.numericKnown = true;
        }
        return v.numericValue;
    }
    return number(value(f,t,loc),loc);
}
static void assignNumber(Frame& f,const Token& dest,double n,const Source& loc) {
    if(!std::isfinite(n)) fail(loc,"numeric result must be finite");
    // strtod may reject rounded subnormals (ERANGE). Preserve the historical
    // format-then-parse behavior on the next numeric read, not at assignment.
    if(n != 0 && std::abs(n) <= 2*std::numeric_limits<double>::min()) {
        assign(f,dest,format(n,loc),loc);
        return;
    }
    auto& v = lookup(f,dest,loc);
    // A pending numeric value already owns fresh empty scalar storage. Reusing
    // that state avoids repeatedly resetting the utility shared_ptr/vector.
    // Every other state still releases its storage (including materialized text,
    // arrays and JSON); textPending is set only here, after that reset.
    if(!v.textPending) v = variable{};
    v.numericValue = n == 0 ? 0 : n; // format(-0) is "0"
    v.numericKnown = true;
    v.textPending = true;
}
static std::vector<std::string>& array(Frame& f,const Token& t,const Source& loc) {
    auto& v = lookup(f,t,loc);
    if(!v.array) fail(loc,"expected array");
    return v.items;
}
static void assignArray(Frame& f,const Token& t,std::vector<std::string> a,const Source& loc) {
    auto& v = lookup(f,t,loc); v = variable{"",std::move(a),true};
}
static void frameCheck(Frame& f,const Source& loc) {
    // At most 24 bytes for a finite binary64 formatted to 17 significant
    // digits (sign, digits, decimal point, exponent). Reserve 32 in accounting
    // while lazy. If close to the limit, materialize and use exact capacities:
    // a conservative reservation must not cause a new early-limit failure.
    auto bytesUsed = [&]() {
        std::size_t bytes = f.extraBytes;
        for(const auto& entry:f.vars) {
            const auto& v = entry.second;
            bytes += entry.first.size() + (v.textPending ? std::max<std::size_t>(32,v.value.capacity()) : v.value.capacity())
                     + v.items.capacity()*sizeof(std::string) + v.structuredBytes;
            if(v.items.size() > itemLimit) failLimit(loc,"array item limit");
            for(const auto& item:v.items) bytes += item.capacity();
        }
        return bytes;
    };
    if(bytesUsed() > frameLimit) {
        for(auto& entry:f.vars) if(entry.second.textPending) textValue(entry.second,loc);
        if(bytesUsed() > frameLimit) failLimit(loc,"frame data limit (2 MiB)");
    }
}
static bool compare(Frame& f,const CodeLine& c) {
    auto a = value(f,c.arg[1],c.source), b = value(f,c.arg[3],c.source);
    double x,y;
    int order;
    if(numeric(a,x) && numeric(b,y)) order = x < y ? -1 : x > y ? 1 : 0;
    else order = a.compare(b);
    auto op = c.arg[2].text;
    if(op == "==") return order == 0;
    if(op == "!=") return order != 0;
    if(op == "<") return order < 0;
    if(op == ">") return order > 0;
    if(op == "<=") return order <= 0;
    return order >= 0;
}
#include "utilities.inc"
#include "stdlib.inc"
#include "worklog.inc"
#include "remote_sql.inc"
#include "web_cgi.inc"
#include "sockets.inc"
static const std::set<std::string> builtins = {
    "websocket","udp","socket","function","kill","include","dll","dllimport","cgi","var","set","math","print","echo",
    "if","else","end","loop","do","while","for","break","continue","point","goto","return","exit",
    "call","env","len","substr","find","replace","upper","lower","trim","split","join","array","file","clock","random",
    "args","input","json","map","try","catch","http","foreach","returnvalue","assert","date","csv","db","sql","web","text","number"
};
vScript::vScript(Options o):options(o),started(std::chrono::steady_clock::now()) {}
void vScript::Load(const std::string& file) {
    loadFile(file,{file,0});
    for(auto& item:functions) validate(item.second);
    auto main = functions.find("main");
    if(main == functions.end() && !options.testMode) fail({file,0},"missing main function");
    if(main != functions.end() && main->second.argc != 0) fail(main->second.source,"main argument count must be zero");
}
void vScript::loadFile(const std::string& path,const Source& origin) {
    std::error_code ec;
    auto full = fs::canonical(path,ec);
    if(ec || !fs::is_regular_file(full,ec)) fail(origin,"cannot read regular source file: " + path);
    auto filename = full.string();
    if(loading.count(filename)) fail(origin,"include cycle: " + filename);
    if(loading.size() >= 32) failLimit(origin,"include depth limit (32)");
    loading.insert(filename);
    std::ifstream in(full,std::ios::binary);
    if(!in) fail(origin,"cannot open source: " + filename);
    vFunc* current = nullptr;
    std::string line;
    std::size_t lineNo = 0;
    while(true) {
        line.clear();
        char ch;
        bool any = false;
        while(in.get(ch)) {
            any = true;
            if(++sourceBytes > 4*1024*1024) failLimit({filename,lineNo+1},"source byte limit (4 MiB)");
            if(ch == '\n') break;
            line += ch;
            if(line.size() > 65536) failLimit({filename,lineNo+1},"line size limit (64 KiB)");
        }
        if(!any) break;
        Source loc{filename,++lineNo};
        if(++sourceLines > 100000) failLimit(loc,"source line limit (100000)");
        CodeLine c; c.source = loc; c.arg = tokenize(line,loc);
        if(c.arg.empty()) continue;
        auto command = c.arg[0].text;
        if(command == "dllimport" || command == "dll" || command == "cgi") fail(loc,"DLL/CGI unavailable in Linux restoration");
        if(command == "function") {
            count(c,2,1024);
            if(current) fail(loc,"nested function; missing kill");
            nameCheck(c.arg[1].text,loc);
            if(builtins.count(c.arg[1].text)) fail(loc,"reserved function name");
            if(functions.size() >= 4096) failLimit(loc,"function count limit");
            vFunc f; f.name = c.arg[1].text; f.source = loc;
            if(c.arg.size()==3 && !identifier(c.arg[2].text))
                f.argc = static_cast<std::size_t>(integer(c.arg[2].text,0,1024,loc));
            else {
                std::set<std::string> names;
                for(std::size_t j=2;j<c.arg.size();++j) {
                    const auto& parameter=c.arg[j]; nameCheck(parameter.text,loc);
                    if(parameter.literal || builtins.count(parameter.text)) fail(loc,"reserved or quoted parameter name");
                    if(!names.insert(parameter.text).second) fail(loc,"duplicate parameter name");
                    f.parameters.push_back(parameter.text);
                }
                f.argc=f.parameters.size();
            }
            auto inserted = functions.emplace(f.name,std::move(f));
            if(!inserted.second) fail(loc,"duplicate function");
            current = &inserted.first->second;
        } else if(command == "kill") {
            count(c,2,2);
            if(!current || c.arg[1].text != current->name) fail(loc,"mismatched kill function");
            current = nullptr;
        } else if(command == "include") {
            count(c,2,2);
            if(current) fail(loc,"include only permitted outside functions");
            loadFile((full.parent_path()/c.arg[1].text).string(),loc);
        } else {
            if(!current) fail(loc,"command outside function");
            current->line.push_back(std::move(c));
        }
    }
    if(in.bad()) fail({filename,lineNo},"source read failed");
    if(current) fail(current->source,"missing kill " + current->name);
    loading.erase(filename);
}
void vScript::validate(vFunc& f) {
    std::vector<std::size_t> stack;
    for(std::size_t i=0;i<f.line.size();++i) {
        auto& c = f.line[i]; const auto& a = c.arg;
        const auto& op = a[0].text;
        c.scope = stack;
        for(auto it=stack.rbegin();it!=stack.rend();++it) {
            const auto& kind=f.line[*it].arg[0].text;
            if(kind=="for" || kind=="foreach" || kind=="loop" || kind=="do") { c.loop = *it; break; }
        }
        auto dest = [&](std::size_t k) { destination(a[k],c.source); };
        if(standardCommand(c)) standardValidate(c);
        else if(op=="args" || op=="input" || op=="json" || op=="map" || op=="http") utilityValidate(c);
        else if(op=="date" || op=="csv" || op=="db") worklogValidate(c);
        else if(op=="sql") remoteSqlValidate(c);
        else if(op=="web") webValidate(c);
        else if(op=="websocket") websocketValidate(c);
        else if(op=="socket") socketValidate(c);
        else if(op=="udp") foundationValidate(c);
        else if(op=="returnvalue") count(c,2,2);
        else if(op=="foreach") { count(c,3,4); dest(1); dest(2); if(a.size()==4) dest(3); }
        else if(op=="try") count(c,1,1);
        else if(op=="catch") { count(c,2,2); dest(1); }
        else if(op == "if" || op == "loop" || op == "while" || op=="assert") {
            count(c,4,4);
            if(std::set<std::string>{"==","!=","<",">","<=",">="}.count(a[2].text) == 0) fail(c.source,"invalid comparison operator");
        } else if(op == "for") { count(c,4,5); dest(1); }
        else if(op == "do" || op == "else" || op == "break" || op == "continue" || op == "exit") count(c,1,1);
        else if(op == "end") count(c,2,2);
        else if(op == "var") { count(c,2,1024); for(std::size_t j=1;j<a.size();++j) nameCheck(a[j].text,c.source); }
        else if(op == "set" || op == "math") { count(c,op == "math" ? 3 : 2,1024); dest(1); }
        else if(op == "print" || op == "echo" || op == "return") count(c,1,1024);
        else if(op == "point" || op == "goto") { count(c,2,2); nameCheck(a[1].text,c.source); }
        else if(op == "call") {
            count(c,3,1024); dest(1);
            auto target = functions.find(a[2].text);
            if(target == functions.end()) fail(c.source,"unknown function: " + a[2].text);
            if(a.size()-3 != target->second.argc) fail(c.source,"invalid argument count for call");
        } else if(op == "env") {
            count(c,4,4);
            if(a[1].text == "get") dest(2);
            else if(a[1].text != "set") fail(c.source,"unknown env operation");
        } else if(op == "len" || op == "upper" || op == "lower" || op == "trim") { count(c,3,3); dest(1); }
        else if(op == "substr" || op == "replace") { count(c,5,5); dest(1); }
        else if(op == "find" || op == "split" || op == "join") { count(c,4,4); dest(1); if(op == "join") dest(2); }
        else if(op == "array") {
            count(c,3,1024); auto sub = a[1].text;
            if(sub == "create") { dest(2); }
            else if(sub == "push") { count(c,4,4); dest(2); }
            else if(sub == "get" || sub == "set") { count(c,5,5); dest(2); if(sub == "get") dest(3); }
            else if(sub == "len" || sub == "pop") { count(c,4,4); dest(2); dest(3); }
            else fail(c.source,"unknown array operation");
        } else if(op == "file") {
            count(c,3,5);
            auto sub=a[1].text;
            if(sub=="close") count(c,3,3);
            else if(sub=="readline") { count(c,5,5); dest(2); dest(3); }
            else {
                count(c,4,4);
                if(sub=="read" || sub=="exists" || sub=="open" || sub=="list") dest(2);
                else if(sub!="write" && sub!="append" && sub!="copy" && sub!="rename" && sub!="create") fail(c.source,"unknown file operation");
            }
        } else if(op == "clock") { count(c,2,2); dest(1); }
        else if(op == "random") { count(c,3,4); if(a[1].text == "seed") count(c,3,3); else { count(c,4,4); dest(1); } }
        else {
            auto target = functions.find(op);
            if(target == functions.end()) fail(c.source,"unknown command: " + op);
            if(a.size()-1 != target->second.argc) fail(c.source,"invalid argument count for " + op);
        }
        if(op == "if" || op == "loop" || op == "do" || op == "for" || op=="foreach" || op=="try") {
            if(stack.size() >= 128) failLimit(c.source,"block nesting limit (128)");
            c.alternate = none; stack.push_back(i);
        } else if(op=="catch") {
            if(stack.empty() || f.line[stack.back()].arg[0].text!="try" || f.line[stack.back()].alternate!=none) fail(c.source,"unmatched or duplicate catch");
            f.line[stack.back()].alternate=i;
        } else if(op == "else") {
            if(stack.empty() || f.line[stack.back()].arg[0].text != "if" || f.line[stack.back()].alternate != none) fail(c.source,"unmatched or duplicate else");
            f.line[stack.back()].alternate = i;
        } else if(op == "end" || op == "while") {
            auto expected = op == "while" ? "do" : a[1].text;
            if(stack.empty() || f.line[stack.back()].arg[0].text != expected || (op == "end" && expected != "if" && expected != "for" && expected != "foreach" && expected != "loop" && expected!="try")) fail(c.source,"mismatched block end");
            if(expected=="try" && f.line[stack.back()].alternate==none) fail(c.source,"try requires catch");
            auto start = stack.back(); stack.pop_back();
            c.match = start; f.line[start].match = i;
            if(f.line[start].alternate != none) f.line[f.line[start].alternate].match = i;
        } else if((op == "break" || op == "continue") && c.loop == none) fail(c.source,op + " outside loop");
    }
    if(!stack.empty()) fail(f.line[stack.back()].source,"unclosed block");
}
struct Exit {};
void flushOutput(const Source& source) {
    std::cout.flush();
    if(!std::cout) fail(source,"output write failed");
}
void vScript::Run() {
    try { runFunction("main",{},0,functions.at("main").source); } catch(const Exit&) {}
    // Exit and ordinary returns both reach this check before CLI success.
    flushOutput(outputSource);
}
bool vScript::Test(const std::string& file) {
    std::size_t passed=0, failed=0;
    for(const auto& entry:functions) {
        if(entry.first.rfind("test_",0)!=0) continue;
        try {
            if(entry.second.argc) fail(entry.second.source,"test functions must take zero arguments");
            vScript isolated(options); isolated.Load(file);
            isolated.runFunction(entry.first,{},0,entry.second.source);
            flushOutput(isolated.outputSource);
            std::cout << "PASS " << entry.first << '\n'; ++passed;
        } catch(const Exit&) {
            std::cerr << entry.second.source.file << ':' << entry.second.source.line << ": error: exit is not a test pass\n";
            std::cout << "FAIL " << entry.first << '\n'; ++failed;
        } catch(const Error& e) {
            std::cerr << e.source.file << ':' << e.source.line << ": error: " << e.what() << '\n';
            std::cout << "FAIL " << entry.first << '\n'; ++failed;
        }
    }
    std::cout << "tests: " << passed << " passed, " << failed << " failed\n";
    flushOutput(outputSource);
    return passed>0 && failed==0;
}
variable vScript::runFunction(const std::string& name,const std::vector<variable>& args,std::size_t depth,const Source& callSite) {
    if(depth >= 64) failLimit(callSite,"recursion limit (64)");
    const auto& f = functions.at(name);
    Frame frame; frame.args = args;
    for(std::size_t i=0;i<f.parameters.size();++i) frame.vars.emplace(f.parameters[i],args[i]);
    for(const auto& arg:args) { frame.extraBytes += variableSize(arg); if(frame.extraBytes > frameLimit) failLimit(callSite,"argument data limit"); }
    frameCheck(frame,callSite);
    struct EachState { variable snapshot; std::size_t index=0; Json::const_iterator it; };
    std::map<std::size_t,EachState> each;
    auto eraseEach=[&](std::size_t key) { auto it=each.find(key); if(it!=each.end()) { frame.extraBytes-=variableSize(it->second.snapshot); each.erase(it); } };
    auto eachPut=[&](std::size_t key) {
        auto& state=each.at(key); const auto& header=f.line[key];
        const auto& v=state.snapshot; auto n=header.arg.size();
        std::string k=std::to_string(state.index);
        if(v.structured) {
            if(v.structured->data.is_object()) k=state.it.key();
            putJson(frame,header.arg[n-2],*state.it,header.source);
        } else assign(frame,header.arg[n-2],v.items[state.index],header.source);
        if(n==4) assign(frame,header.arg[1],k,header.source);
    };
    struct ForState { double end,step; };
    std::map<std::size_t,ForState> fors;
    for(std::size_t pc=0;pc<f.line.size();) {
        const auto& c = f.line[pc]; const auto& a = c.arg; const auto& s = c.source;
        // Borrow validated command text. string_view comparisons keep literal
        // dispatch local even when utility templates enlarge this translation unit.
        const std::string_view op = a[0].text;
        if(++steps > options.maxSteps) failLimit(s,"step limit exceeded");
        auto val = [&](std::size_t k) { return value(frame,a[k],s); };
        auto put = [&](std::size_t k,const std::string& text) { assign(frame,a[k],text,s); };
        auto concat = [&](std::size_t k) { std::string result; for(;k<a.size();++k) append(result,val(k),s); return result; };
        std::size_t next = pc+1;
        try {
        if(op == "var") {
            for(std::size_t i=1;i<a.size();++i) {
                if(frame.vars.size() >= 4096 && !frame.vars.count(a[i].text)) failLimit(s,"variable count limit");
                frame.vars.emplace(a[i].text,variable{});
            }
        } else if(op == "set") put(1,concat(2));
        else if(op == "math") {
            std::string expr;
            if(!c.math.ready) for(std::size_t i=2;i<a.size();++i) { if(i>2) append(expr," ",s); append(expr,a[i].text,s); }
            assignNumber(frame,a[1],expression(expr,s,[&](const std::string& token) { return numericValue(frame,{token,false},s); },c.math),s);
        } else if(op == "print" || op == "echo") {
            auto text = concat(1);
            emit(text,s);
        } else if(op == "return") return variable{concat(1),{},false};
        else if(op == "returnvalue") return typedValue(frame,a[1],s);
        else if(op == "assert") { if(!compare(frame,c)) fail(s,"assertion failed"); }
        else if(op == "exit") throw Exit{};
        else if(op == "if") { if(!compare(frame,c)) next = c.alternate == none ? c.match+1 : c.alternate+1; }
        else if(op == "else") next = c.match+1;
        else if(op == "loop") { if(!compare(frame,c)) next = c.match+1; }
        else if(op == "do") {}
        else if(op == "while") { if(compare(frame,c)) next = c.match+1; }
        else if(op == "foreach") {
            lookup(frame,a[1],s); if(a.size()==4) { lookup(frame,a[2],s); if(a[1].text==a[2].text) fail(s,"foreach destinations must differ"); }
            auto snapshot=typedValue(frame,a.back(),s);
            if(!snapshot.array && (!snapshot.structured || !snapshot.structured->data.is_structured())) fail(s,"foreach requires array or object");
            auto size=snapshot.array ? snapshot.items.size() : snapshot.structured->data.size();
            eraseEach(pc);
            if(!size) next=c.match+1;
            else {
                frame.extraBytes+=variableSize(snapshot); frameCheck(frame,s);
                auto& state=each[pc]; state.snapshot=std::move(snapshot);
                if(state.snapshot.structured) state.it=state.snapshot.structured->data.cbegin();
                eachPut(pc);
            }
        }
        else if(op == "for") {
            double start = numericValue(frame,a[2],s), end = numericValue(frame,a[3],s), step = a.size() == 5 ? numericValue(frame,a[4],s) : 1;
            if(step == 0) fail(s,"for step must not be zero");
            assignNumber(frame,a[1],start,s);
            if(step > 0 ? start >= end : start <= end) next = c.match+1;
            else fors[pc] = {end,step};
        } else if(op == "end") {
            const std::string_view kind = a[1].text;
            if(kind == "loop") next = c.match;
            else if(kind=="foreach") {
                auto& state=each.at(c.match); auto& v=state.snapshot;
                auto size=v.array ? v.items.size() : v.structured->data.size();
                if(++state.index<size) { if(v.structured) ++state.it; eachPut(c.match); next=c.match+1; }
                else eraseEach(c.match);
            }
            else if(kind == "for") {
                auto it = fors.find(c.match);
                if(it == fors.end()) fail(s,"inactive for block");
                const auto& dest = f.line[c.match].arg[1];
                double old = numericValue(frame,dest,s), n = old+it->second.step;
                if(!std::isfinite(n)) fail(s,"for result must be finite");
                if(n == old) fail(s,"for step makes no numeric progress");
                assignNumber(frame,dest,n,s);
                if(it->second.step > 0 ? n < it->second.end : n > it->second.end) next = c.match+1;
                else fors.erase(it);
            }
        } else if(op == "break" || op == "continue") {
            auto end = f.line[c.loop].match;
            if(op == "break") { fors.erase(c.loop); eraseEach(c.loop); next = end+1; }
            else next = end;
        } else if(op == "point") frame.points[a[1].text] = pc;
        else if(op == "goto") {
            auto it = frame.points.find(a[1].text);
            if(it == frame.points.end()) fail(s,"point has not been visited");
            if(f.line[it->second].scope != c.scope) fail(s,"goto cannot cross block boundaries");
            for(auto scope:c.scope) {
                const auto& block=f.line[scope];
                if(block.arg[0].text=="try" && (pc<block.alternate)!=(it->second<block.alternate))
                    fail(s,"goto cannot cross catch boundary");
            }
            next = it->second+1;
        } else if(op == "env") {
            if(a[1].text == "get") {
                auto key=val(3);
                if(key.empty() || key.find('=') != std::string::npos || key.find('\0') != std::string::npos) fail(s,"invalid environment name");
                auto changed=changedEnvironment.find(key);
                if(changed != changedEnvironment.end()) put(2,changed->second);
                else { const char* p=std::getenv(key.c_str()); put(2,p ? p : ""); }
            } else {
                auto key=val(2), text=val(3);
                if(key.empty() || key.find('=') != std::string::npos || key.find('\0') != std::string::npos || text.find('\0') != std::string::npos) fail(s,"invalid environment name/value");
                // Script-local overlay keeps this implementation standard-library only.
                if(changedEnvironment.size() >= 4096 && !changedEnvironment.count(key)) failLimit(s,"environment entry limit");
                std::size_t bytes=key.size()+text.size();
                for(const auto& entry:changedEnvironment) if(entry.first != key) bytes+=entry.first.size()+entry.second.size();
                if(bytes > frameLimit) failLimit(s,"environment data limit (2 MiB)");
                changedEnvironment[key]=std::move(text);
            }
        } else if(op == "len") put(1,std::to_string(val(2).size()));
        else if(op == "substr") {
            auto text=val(2); auto start=static_cast<std::size_t>(integer(val(3),0,static_cast<long long>(text.size()),s));
            auto length=static_cast<std::size_t>(integer(val(4),0,static_cast<long long>(valueLimit),s));
            put(1,text.substr(start,length));
        } else if(op == "find") {
            auto at=val(2).find(val(3)); put(1,at == std::string::npos ? "-1" : std::to_string(at));
        } else if(op == "replace") {
            auto text=val(2), needle=val(3), replacement=val(4); std::string result;
            if(needle.empty()) fail(s,"empty replace needle");
            std::size_t pos=0,at;
            while((at=text.find(needle,pos)) != std::string::npos) {
                append(result,text.substr(pos,at-pos),s); append(result,replacement,s); pos=at+needle.size();
            }
            append(result,text.substr(pos),s); put(1,result);
        } else if(op == "upper" || op == "lower" || op == "trim") {
            auto text=val(2);
            if(op == "trim") {
                auto begin=text.find_first_not_of(" \t\r\n\f\v"), end=text.find_last_not_of(" \t\r\n\f\v");
                text=begin == std::string::npos ? "" : text.substr(begin,end-begin+1);
            } else for(char& ch:text) {
                if(op == "upper" && ch >= 'a' && ch <= 'z') ch=static_cast<char>(ch-'a'+'A');
                if(op == "lower" && ch >= 'A' && ch <= 'Z') ch=static_cast<char>(ch-'A'+'a');
            }
            put(1,text);
        } else if(op == "split") {
            auto text=val(2), delimiter=val(3); std::vector<std::string> parts;
            if(delimiter.empty()) fail(s,"empty split delimiter");
            std::size_t pos=0,at;
            while((at=text.find(delimiter,pos)) != std::string::npos) {
                if(parts.size() >= itemLimit-1) failLimit(s,"array item limit");
                parts.push_back(text.substr(pos,at-pos)); pos=at+delimiter.size();
            }
            parts.push_back(text.substr(pos)); assignArray(frame,a[1],std::move(parts),s);
        } else if(op == "join") {
            const auto& items=array(frame,a[2],s); auto delimiter=val(3); std::string text;
            for(std::size_t i=0;i<items.size();++i) { if(i) append(text,delimiter,s); append(text,items[i],s); }
            put(1,text);
        } else if(standardCommand(c)) { standardExecute(frame,c);
        } else if(op == "array") {
            auto sub=a[1].text;
            if(sub == "create") {
                std::vector<std::string> items;
                std::size_t bytes=0;
                for(std::size_t i=3;i<a.size();++i) {
                    auto text=val(i); bytes+=text.size()+sizeof(std::string);
                    if(bytes>frameLimit) failLimit(s,"array data limit (2 MiB)");
                    items.push_back(std::move(text));
                }
                assignArray(frame,a[2],std::move(items),s);
            } else if(sub == "push") {
                auto text=val(3); auto& items=array(frame,a[2],s);
                if(items.size() >= itemLimit) failLimit(s,"array item limit");
                items.push_back(std::move(text));
            } else if(sub == "set") {
                auto text=val(4); auto& items=array(frame,a[2],s);
                auto index=static_cast<std::size_t>(integer(val(3),0,static_cast<long long>(itemLimit),s));
                if(index >= items.size()) fail(s,"array index out of bounds");
                items[index]=std::move(text);
            } else {
                auto& items=array(frame,a[3],s); std::string result;
                if(sub == "len") result=std::to_string(items.size());
                else if(sub == "pop") {
                    if(items.empty()) fail(s,"cannot pop empty array");
                    result=items.back(); items.pop_back();
                } else {
                    auto index=static_cast<std::size_t>(integer(val(4),0,static_cast<long long>(itemLimit),s));
                    if(index >= items.size()) fail(s,"array index out of bounds");
                    result=items[index];
                }
                put(2,result);
            }
        } else if(op=="try") { lookup(frame,f.line[c.alternate].arg[1],s); }
        else if(op=="catch") next=c.match+1;
        else if(utility(frame,c)) {}
        else if(worklog(frame,c)) {}
        else if(remoteSql(frame,c)) {}
        else if(webCommand(frame,c)) {}
        else if(op=="websocket") { websocketCommand(frame,c); }
        else if(op=="socket") { socketCommand(frame,c); }
        else if(op=="udp") { foundationCommand(frame,c); }
        else if(op == "file") {
            if(!options.allowFiles) fail(s,"file operations require --allow-files (not a sandbox)");
            auto sub=a[1].text, path=val(sub == "read" || sub == "exists" ? 3 : 2);
            if(path.empty() || path.find('\0') != std::string::npos) fail(s,"invalid file path");
            std::error_code ec;
            bool exists=fs::exists(path,ec);
            if(ec) fail(s,"file status failed: " + ec.message());
            if(sub == "exists") put(2,exists ? "1" : "0");
            else if(sub == "read") {
                if(!fs::is_regular_file(path,ec) || ec) fail(s,"file read requires regular file");
                std::ifstream in(path,std::ios::binary); if(!in) fail(s,"file read open failed");
                std::string text; char buffer[8192];
                while(in.read(buffer,sizeof(buffer)) || in.gcount()) append(text,std::string(buffer,static_cast<std::size_t>(in.gcount())),s);
                if(in.bad()) fail(s,"file read failed");
                put(2,text);
            } else {
                auto text=val(3);
                if(text.size() > 16*1024*1024-fileBytes) failLimit(s,"file write byte limit (16 MiB)");
                fileBytes+=text.size();
                if(exists && (!fs::is_regular_file(path,ec) || ec)) fail(s,"file write requires regular file");
                std::ofstream out(path,std::ios::binary | (sub == "append" ? std::ios::app : std::ios::trunc));
                if(!out) fail(s,"file write open failed");
                out.write(text.data(),static_cast<std::streamsize>(text.size())); out.close();
                if(!out) fail(s,"file write failed");
            }
        } else if(op == "clock") {
            auto ms=std::chrono::duration<double,std::milli>(std::chrono::steady_clock::now()-started).count(); put(1,format(ms,s));
        } else if(op == "random") {
            if(a[1].text == "seed") randomEngine.seed(static_cast<std::uint32_t>(integer(val(2),0,4294967295LL,s)));
            else {
                auto low=integer(val(2),-2147483648LL,2147483647LL,s), high=integer(val(3),-2147483648LL,2147483647LL,s);
                if(high < low) fail(s,"invalid random range");
                // Explicit rejection mapping gives identical sequences across standard libraries.
                auto range=static_cast<std::uint64_t>(high-low)+1;
                auto limit=(std::uint64_t{1}<<32)/range*range;
                std::uint64_t draw;
                do { draw=randomEngine(); } while(draw >= limit);
                put(1,std::to_string(low+static_cast<long long>(draw%range)));
            }
        } else {
            bool capture=op == "call";
            std::vector<variable> values;
            std::size_t bytes=0;
            for(std::size_t i=capture ? 3 : 1;i<a.size();++i) {
                auto text=typedValue(frame,a[i],s); bytes+=variableSize(text); if(bytes>frameLimit) failLimit(s,"argument data limit"); values.push_back(std::move(text));
            }
            if(capture) lookup(frame,a[1],s); // validate destination before callee side effects
            auto result=runFunction(capture ? a[2].text : a[0].text,values,depth+1,s);
            if(capture) lookup(frame,a[1],s)=std::move(result);
        }
        frameCheck(frame,s);
        } catch(const Error& error) {
            if(error.fatal) throw;
            bool handled=false;
            // Lexical handlers need no mutable stack: nonlocal exits cannot
            // leave stale handlers behind. A catch never catches itself.
            for(auto it=c.scope.rbegin();it!=c.scope.rend();++it) {
                const auto& start=f.line[*it];
                if(start.arg[0].text!="try" || pc>=start.alternate) continue;
                const auto& handler=f.line[start.alternate];
                assign(frame,handler.arg[1],error.source.file+":"+std::to_string(error.source.line)+": error: "+error.what(),handler.source);
                for(auto loop=fors.begin();loop!=fors.end();) {
                    if(loop->first>*it && loop->first<start.match) loop=fors.erase(loop); else ++loop;
                }
                for(auto loop=each.begin();loop!=each.end();) {
                    auto key=loop->first; ++loop;
                    if(key>*it && key<start.match) eraseEach(key);
                }
                frameCheck(frame,handler.source);
                next=start.alternate+1; handled=true; break;
            }
            if(!handled) throw;
        }
        pc=next;
    }
    return variable{};
}
} // namespace vanilla