Module refinery.lib.scripts.bat.state
Expand source code Browse git
from __future__ import annotations
import ntpath
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from random import Random
from uuid import uuid4
from refinery.lib.dt import date_from_timestamp, isodate
from refinery.lib.scripts.bat.model import EmulatorException, MissingVariable
from refinery.lib.scripts.win32const import make_win32_environment
STATEMENT_BUDGET = 100_000
"""
The maximum number of statements a single emulation may execute. Scripts stuck in
an undetectable loop are cut off at this bound instead of running forever.
"""
DEPTH_LIMIT = 100
"""
The maximum nesting depth, counting both structural nesting (parentheses and blocks) and nested
sub-shells (CALL, START, CMD, FOR /F), that a single emulation may reach. Scripts nested past this
bound are cut off instead of exhausting the Python call stack.
"""
@dataclass
class ExecutionContext:
"""
Resource guards shared by every `BatchState` cloned during one emulation. The statement budget
and the nesting depth are enforced across the whole clone tree, so nested sub-shells cannot each
spend a fresh budget or a fresh recursion allowance and thereby multiply the bound.
"""
statement_budget: int = STATEMENT_BUDGET
depth_limit: int = DEPTH_LIMIT
statement_count: int = 0
depth: int = 0
def count_statement(self):
self.statement_count += 1
if self.statement_count > self.statement_budget:
raise EmulatorException(
F'The emulation exceeded its budget of {self.statement_budget} statements '
'and was aborted, likely because it is stuck in a loop.')
@contextmanager
def descend(self):
self.depth += 1
try:
if self.depth > self.depth_limit:
raise EmulatorException(
F'The emulation exceeded its maximum nesting depth of {self.depth_limit} '
'and was aborted.')
yield
finally:
self.depth -= 1
class ErrorZero(int, Enum):
Val = 0
def __bool__(self):
return True
def __str__(self):
return '0'
__repr__ = __str__
class RetainVariable(str, Enum):
Val = ''
class BatchState:
name: str | None
args: list[str]
now: datetime
start_time: datetime
environment_stack: list[dict[str, str | RetainVariable]]
delayexpand_stack: list[bool]
cmdextended_stack: list[bool]
_for_loops: list[dict[str, str]]
file_system: dict[str, str]
def __init__(
self,
delayexpand: bool = False,
extensions_enabled: bool = True,
extensions_version: int = 2,
environment: dict | None = None,
file_system: dict | None = None,
username: str = 'Administrator',
hostname: str | None = None,
now: int | float | str | datetime | None = None,
cwd: str = 'C:\\',
filename: str | None | ellipsis = ...,
echo: bool = True,
codec: str = 'cp1252',
cmdline: bool = False,
context: ExecutionContext | None = None,
):
self.extensions_version = extensions_version
file_system = file_system or {}
environment = environment or {}
if hostname is None:
hostname = str(uuid4())
for key, value in make_win32_environment(username, hostname).items():
environment.setdefault(key.upper(), value)
if isinstance(now, str):
now = isodate(now)
if isinstance(now, (int, float)):
now = date_from_timestamp(now)
if now is None:
now = datetime.now()
self.cwd = cwd
self.now = now
self.start_time = now
self._random = Random()
self.context = context if context is not None else ExecutionContext()
self.hostname = hostname
self.username = username
self.labels = {}
self._for_loops = []
self.environment_stack = [environment]
self.delayexpand_stack = [delayexpand]
self.cmdextended_stack = [extensions_enabled]
self.file_system = file_system
self.dirstack = []
self.linebreaks = []
if filename is ... or filename == '':
filename = F'{uuid4()}.bat'
self.name = filename
self.args = []
self._cmd = ''
self.ec = None
self.echo = echo
self.codec = codec
self.cmdline = cmdline
@property
def cwd(self):
return self._cwd
@cwd.setter
def cwd(self, new: str):
new = new.replace('/', '\\')
if not new.endswith('\\'):
new = F'{new}\\'
if not ntpath.isabs(new):
new = ntpath.join(self.cwd, new)
if not ntpath.isabs(new):
raise ValueError(F'Invalid absolute path: {new}')
self._cwd = ntpath.normcase(ntpath.normpath(new))
def try_chdir(self, target: str) -> bool:
"""
Attempt to change the working directory to `target`, mirroring cmd.exe: a target that
cannot be resolved to an absolute path leaves the directory unchanged and returns `False`.
"""
try:
self.cwd = target
except ValueError:
return False
return True
@property
def ec(self) -> int | ErrorZero:
return self.errorlevel
@ec.setter
def ec(self, value: int | ErrorZero | None):
ec = value or 0
self.environment['ERRORLEVEL'] = str(ec)
self.errorlevel = ec
@property
def command_line(self):
return self._cmd
@command_line.setter
def command_line(self, value: str):
self._cmd = value
self.args = value.split()
def envar(self, name: str, default: str | None = None) -> str | RetainVariable:
name = name.upper()
if name in (e := self.environment):
return e[name]
elif name == 'DATE':
return self.now.strftime('%Y-%m-%d')
elif name == 'TIME':
time = self.now.strftime('%M:%S,%f')
return F'{self.now.hour:2d}:{time:.8}'
elif name == 'RANDOM':
return str(self._random.randrange(0, 32768))
elif name == 'ERRORLEVEL':
return str(self.ec)
elif name == 'CD':
return self.cwd
elif name == 'CMDCMDLINE':
line = self.envar('COMSPEC', 'cmd.exe')
if args := self.args:
args = ' '.join(args)
line = F'{line} /c "{args}"'
return line
elif name == 'CMDEXTVERSION':
return str(self.extensions_version)
elif name == 'HIGHESTNUMANODENUMBER':
return '0'
elif default is not None:
return default
else:
raise MissingVariable
def resolve_path(self, path: str) -> str:
if not ntpath.isabs(path):
path = ntpath.join(self.cwd, path)
return ntpath.normcase(ntpath.normpath(path))
def count_statement(self):
self.context.count_statement()
def create_file(self, path: str, data: str = ''):
self.file_system[self.resolve_path(path)] = data
def append_file(self, path: str, data: str):
path = self.resolve_path(path)
if left := self.file_system.get(path, None):
data = F'{left}{data}'
self.file_system[path] = data
def remove_file(self, path: str):
self.file_system.pop(self.resolve_path(path), None)
def ingest_file(self, path: str) -> str | None:
return self.file_system.get(self.resolve_path(path))
def exists_file(self, path: str) -> bool:
return self.resolve_path(path) in self.file_system
def sizeof_file(self, path: str) -> int:
if data := self.ingest_file(path):
return len(data)
return -1
def new_forloop(self) -> dict[str, str]:
new = {}
old = self.for_loop_variables
if old is not None:
new.update(old)
self._for_loops.append(new)
return new
def end_forloop(self):
self._for_loops.pop()
@property
def environment(self):
return self.environment_stack[-1]
def display_variables(self) -> list[tuple[str, str | RetainVariable]]:
"""
The variables a bare or prefix `SET` lists, sorted case-insensitively by name. `ERRORLEVEL`
is materialized on every error-level write (so `%ERRORLEVEL%` and `if defined errorlevel`
resolve) but is a pseudo-variable that cmd.exe hides from `SET` output, so it is excluded.
"""
return sorted(
((name, value) for name, value in self.environment.items() if name != 'ERRORLEVEL'),
key=lambda item: item[0].upper(),
)
@property
def delayexpand(self):
return self.delayexpand_stack[-1]
@delayexpand.setter
def delayexpand(self, v):
self.delayexpand_stack[-1] = v
@property
def cmdextended(self):
return self.cmdextended_stack[-1]
@cmdextended.setter
def cmdextended(self, v):
self.cmdextended_stack[-1] = v
@property
def for_loop_variables(self):
if vars := self._for_loops:
return vars[-1]
else:
return None
Global variables
var STATEMENT_BUDGET-
The maximum number of statements a single emulation may execute. Scripts stuck in an undetectable loop are cut off at this bound instead of running forever.
var DEPTH_LIMIT-
The maximum nesting depth, counting both structural nesting (parentheses and blocks) and nested sub-shells (CALL, START, CMD, FOR /F), that a single emulation may reach. Scripts nested past this bound are cut off instead of exhausting the Python call stack.
Classes
class ExecutionContext (statement_budget=100000, depth_limit=100, statement_count=0, depth=0)-
Resource guards shared by every
BatchStatecloned during one emulation. The statement budget and the nesting depth are enforced across the whole clone tree, so nested sub-shells cannot each spend a fresh budget or a fresh recursion allowance and thereby multiply the bound.Expand source code Browse git
@dataclass class ExecutionContext: """ Resource guards shared by every `BatchState` cloned during one emulation. The statement budget and the nesting depth are enforced across the whole clone tree, so nested sub-shells cannot each spend a fresh budget or a fresh recursion allowance and thereby multiply the bound. """ statement_budget: int = STATEMENT_BUDGET depth_limit: int = DEPTH_LIMIT statement_count: int = 0 depth: int = 0 def count_statement(self): self.statement_count += 1 if self.statement_count > self.statement_budget: raise EmulatorException( F'The emulation exceeded its budget of {self.statement_budget} statements ' 'and was aborted, likely because it is stuck in a loop.') @contextmanager def descend(self): self.depth += 1 try: if self.depth > self.depth_limit: raise EmulatorException( F'The emulation exceeded its maximum nesting depth of {self.depth_limit} ' 'and was aborted.') yield finally: self.depth -= 1Instance variables
var statement_budget-
The type of the None singleton.
var depth_limit-
The type of the None singleton.
var statement_count-
The type of the None singleton.
var depth-
The type of the None singleton.
Methods
def count_statement(self)-
Expand source code Browse git
def count_statement(self): self.statement_count += 1 if self.statement_count > self.statement_budget: raise EmulatorException( F'The emulation exceeded its budget of {self.statement_budget} statements ' 'and was aborted, likely because it is stuck in a loop.') def descend(self)-
Expand source code Browse git
@contextmanager def descend(self): self.depth += 1 try: if self.depth > self.depth_limit: raise EmulatorException( F'The emulation exceeded its maximum nesting depth of {self.depth_limit} ' 'and was aborted.') yield finally: self.depth -= 1
class ErrorZero (*args, **kwds)-
int([x]) -> integer int(x, base=10) -> integer
Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating-point numbers, this truncates towards zero.
If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer iteral.
>>> int('0b100', base=0) 4Expand source code Browse git
class ErrorZero(int, Enum): Val = 0 def __bool__(self): return True def __str__(self): return '0' __repr__ = __str__Ancestors
- builtins.int
- enum.Enum
Class variables
var Val-
The type of the None singleton.
class RetainVariable (*args, **kwds)-
str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str
Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.
Expand source code Browse git
class RetainVariable(str, Enum): Val = ''Ancestors
- builtins.str
- enum.Enum
Class variables
var Val-
The type of the None singleton.
class BatchState (delayexpand=False, extensions_enabled=True, extensions_version=2, environment=None, file_system=None, username='Administrator', hostname=None, now=None, cwd='C:\\', filename=Ellipsis, echo=True, codec='cp1252', cmdline=False, context=None)-
Expand source code Browse git
class BatchState: name: str | None args: list[str] now: datetime start_time: datetime environment_stack: list[dict[str, str | RetainVariable]] delayexpand_stack: list[bool] cmdextended_stack: list[bool] _for_loops: list[dict[str, str]] file_system: dict[str, str] def __init__( self, delayexpand: bool = False, extensions_enabled: bool = True, extensions_version: int = 2, environment: dict | None = None, file_system: dict | None = None, username: str = 'Administrator', hostname: str | None = None, now: int | float | str | datetime | None = None, cwd: str = 'C:\\', filename: str | None | ellipsis = ..., echo: bool = True, codec: str = 'cp1252', cmdline: bool = False, context: ExecutionContext | None = None, ): self.extensions_version = extensions_version file_system = file_system or {} environment = environment or {} if hostname is None: hostname = str(uuid4()) for key, value in make_win32_environment(username, hostname).items(): environment.setdefault(key.upper(), value) if isinstance(now, str): now = isodate(now) if isinstance(now, (int, float)): now = date_from_timestamp(now) if now is None: now = datetime.now() self.cwd = cwd self.now = now self.start_time = now self._random = Random() self.context = context if context is not None else ExecutionContext() self.hostname = hostname self.username = username self.labels = {} self._for_loops = [] self.environment_stack = [environment] self.delayexpand_stack = [delayexpand] self.cmdextended_stack = [extensions_enabled] self.file_system = file_system self.dirstack = [] self.linebreaks = [] if filename is ... or filename == '': filename = F'{uuid4()}.bat' self.name = filename self.args = [] self._cmd = '' self.ec = None self.echo = echo self.codec = codec self.cmdline = cmdline @property def cwd(self): return self._cwd @cwd.setter def cwd(self, new: str): new = new.replace('/', '\\') if not new.endswith('\\'): new = F'{new}\\' if not ntpath.isabs(new): new = ntpath.join(self.cwd, new) if not ntpath.isabs(new): raise ValueError(F'Invalid absolute path: {new}') self._cwd = ntpath.normcase(ntpath.normpath(new)) def try_chdir(self, target: str) -> bool: """ Attempt to change the working directory to `target`, mirroring cmd.exe: a target that cannot be resolved to an absolute path leaves the directory unchanged and returns `False`. """ try: self.cwd = target except ValueError: return False return True @property def ec(self) -> int | ErrorZero: return self.errorlevel @ec.setter def ec(self, value: int | ErrorZero | None): ec = value or 0 self.environment['ERRORLEVEL'] = str(ec) self.errorlevel = ec @property def command_line(self): return self._cmd @command_line.setter def command_line(self, value: str): self._cmd = value self.args = value.split() def envar(self, name: str, default: str | None = None) -> str | RetainVariable: name = name.upper() if name in (e := self.environment): return e[name] elif name == 'DATE': return self.now.strftime('%Y-%m-%d') elif name == 'TIME': time = self.now.strftime('%M:%S,%f') return F'{self.now.hour:2d}:{time:.8}' elif name == 'RANDOM': return str(self._random.randrange(0, 32768)) elif name == 'ERRORLEVEL': return str(self.ec) elif name == 'CD': return self.cwd elif name == 'CMDCMDLINE': line = self.envar('COMSPEC', 'cmd.exe') if args := self.args: args = ' '.join(args) line = F'{line} /c "{args}"' return line elif name == 'CMDEXTVERSION': return str(self.extensions_version) elif name == 'HIGHESTNUMANODENUMBER': return '0' elif default is not None: return default else: raise MissingVariable def resolve_path(self, path: str) -> str: if not ntpath.isabs(path): path = ntpath.join(self.cwd, path) return ntpath.normcase(ntpath.normpath(path)) def count_statement(self): self.context.count_statement() def create_file(self, path: str, data: str = ''): self.file_system[self.resolve_path(path)] = data def append_file(self, path: str, data: str): path = self.resolve_path(path) if left := self.file_system.get(path, None): data = F'{left}{data}' self.file_system[path] = data def remove_file(self, path: str): self.file_system.pop(self.resolve_path(path), None) def ingest_file(self, path: str) -> str | None: return self.file_system.get(self.resolve_path(path)) def exists_file(self, path: str) -> bool: return self.resolve_path(path) in self.file_system def sizeof_file(self, path: str) -> int: if data := self.ingest_file(path): return len(data) return -1 def new_forloop(self) -> dict[str, str]: new = {} old = self.for_loop_variables if old is not None: new.update(old) self._for_loops.append(new) return new def end_forloop(self): self._for_loops.pop() @property def environment(self): return self.environment_stack[-1] def display_variables(self) -> list[tuple[str, str | RetainVariable]]: """ The variables a bare or prefix `SET` lists, sorted case-insensitively by name. `ERRORLEVEL` is materialized on every error-level write (so `%ERRORLEVEL%` and `if defined errorlevel` resolve) but is a pseudo-variable that cmd.exe hides from `SET` output, so it is excluded. """ return sorted( ((name, value) for name, value in self.environment.items() if name != 'ERRORLEVEL'), key=lambda item: item[0].upper(), ) @property def delayexpand(self): return self.delayexpand_stack[-1] @delayexpand.setter def delayexpand(self, v): self.delayexpand_stack[-1] = v @property def cmdextended(self): return self.cmdextended_stack[-1] @cmdextended.setter def cmdextended(self, v): self.cmdextended_stack[-1] = v @property def for_loop_variables(self): if vars := self._for_loops: return vars[-1] else: return NoneClass variables
var name-
The type of the None singleton.
var args-
The type of the None singleton.
var now-
The type of the None singleton.
var start_time-
The type of the None singleton.
var environment_stack-
The type of the None singleton.
var delayexpand_stack-
The type of the None singleton.
var cmdextended_stack-
The type of the None singleton.
var file_system-
The type of the None singleton.
Instance variables
var cwd-
Expand source code Browse git
@property def cwd(self): return self._cwd var ec-
Expand source code Browse git
@property def ec(self) -> int | ErrorZero: return self.errorlevel var command_line-
Expand source code Browse git
@property def command_line(self): return self._cmd var environment-
Expand source code Browse git
@property def environment(self): return self.environment_stack[-1] var delayexpand-
Expand source code Browse git
@property def delayexpand(self): return self.delayexpand_stack[-1] var cmdextended-
Expand source code Browse git
@property def cmdextended(self): return self.cmdextended_stack[-1] var for_loop_variables-
Expand source code Browse git
@property def for_loop_variables(self): if vars := self._for_loops: return vars[-1] else: return None
Methods
def try_chdir(self, target)-
Attempt to change the working directory to
target, mirroring cmd.exe: a target that cannot be resolved to an absolute path leaves the directory unchanged and returnsFalse.Expand source code Browse git
def try_chdir(self, target: str) -> bool: """ Attempt to change the working directory to `target`, mirroring cmd.exe: a target that cannot be resolved to an absolute path leaves the directory unchanged and returns `False`. """ try: self.cwd = target except ValueError: return False return True def envar(self, name, default=None)-
Expand source code Browse git
def envar(self, name: str, default: str | None = None) -> str | RetainVariable: name = name.upper() if name in (e := self.environment): return e[name] elif name == 'DATE': return self.now.strftime('%Y-%m-%d') elif name == 'TIME': time = self.now.strftime('%M:%S,%f') return F'{self.now.hour:2d}:{time:.8}' elif name == 'RANDOM': return str(self._random.randrange(0, 32768)) elif name == 'ERRORLEVEL': return str(self.ec) elif name == 'CD': return self.cwd elif name == 'CMDCMDLINE': line = self.envar('COMSPEC', 'cmd.exe') if args := self.args: args = ' '.join(args) line = F'{line} /c "{args}"' return line elif name == 'CMDEXTVERSION': return str(self.extensions_version) elif name == 'HIGHESTNUMANODENUMBER': return '0' elif default is not None: return default else: raise MissingVariable def resolve_path(self, path)-
Expand source code Browse git
def resolve_path(self, path: str) -> str: if not ntpath.isabs(path): path = ntpath.join(self.cwd, path) return ntpath.normcase(ntpath.normpath(path)) def count_statement(self)-
Expand source code Browse git
def count_statement(self): self.context.count_statement() def create_file(self, path, data='')-
Expand source code Browse git
def create_file(self, path: str, data: str = ''): self.file_system[self.resolve_path(path)] = data def append_file(self, path, data)-
Expand source code Browse git
def append_file(self, path: str, data: str): path = self.resolve_path(path) if left := self.file_system.get(path, None): data = F'{left}{data}' self.file_system[path] = data def remove_file(self, path)-
Expand source code Browse git
def remove_file(self, path: str): self.file_system.pop(self.resolve_path(path), None) def ingest_file(self, path)-
Expand source code Browse git
def ingest_file(self, path: str) -> str | None: return self.file_system.get(self.resolve_path(path)) def exists_file(self, path)-
Expand source code Browse git
def exists_file(self, path: str) -> bool: return self.resolve_path(path) in self.file_system def sizeof_file(self, path)-
Expand source code Browse git
def sizeof_file(self, path: str) -> int: if data := self.ingest_file(path): return len(data) return -1 def new_forloop(self)-
Expand source code Browse git
def new_forloop(self) -> dict[str, str]: new = {} old = self.for_loop_variables if old is not None: new.update(old) self._for_loops.append(new) return new def end_forloop(self)-
Expand source code Browse git
def end_forloop(self): self._for_loops.pop() def display_variables(self)-
The variables a bare or prefix
SETlists, sorted case-insensitively by name.ERRORLEVELis materialized on every error-level write (so%ERRORLEVEL%andif defined errorlevelresolve) but is a pseudo-variable that cmd.exe hides fromSEToutput, so it is excluded.Expand source code Browse git
def display_variables(self) -> list[tuple[str, str | RetainVariable]]: """ The variables a bare or prefix `SET` lists, sorted case-insensitively by name. `ERRORLEVEL` is materialized on every error-level write (so `%ERRORLEVEL%` and `if defined errorlevel` resolve) but is a pseudo-variable that cmd.exe hides from `SET` output, so it is excluded. """ return sorted( ((name, value) for name, value in self.environment.items() if name != 'ERRORLEVEL'), key=lambda item: item[0].upper(), )