// SPDX-License-Identifier: GPL-3.0-only unit UPython; {$mode objfpc}{$H+} interface uses Classes, SysUtils, UProcessAuto; const DefaultPythonBin = {$IFDEF WINDOWS}'pyw'{$ELSE}'python3'{$ENDIF}; {$IFDEF DARWIN} UserPythonBin = '/usr/local/bin/python3'; {$ENDIF} type TReceiveLineEvent = procedure(ASender: TObject; ALine: UTF8String) of object; TCommandEvent = procedure(ASender: TObject; ACommand, AParam: UTF8String; out AResult: UTF8String) of object; TWarningEvent = procedure(ASender: TObject; AMessage: UTF8String; out AProceed: boolean) of object; { TPythonScript } TPythonScript = class private FCheckScriptSecure: boolean; FPythonBin: string; FPythonVersion: string; FLinePrefix: RawByteString; FOnCommand: TCommandEvent; FOnError: TReceiveLineEvent; FOnBusy: TNotifyEvent; FOnWarning: TWarningEvent; FOnOutputLine: TReceiveLineEvent; FPythonSend: TSendLineMethod; FErrorText: UTF8String; FFirstOutput: boolean; function GetPythonVersionMajor: integer; procedure PythonError(ALine: RawByteString); procedure PythonOutput(ALine: RawByteString); procedure PythonBusy(var {%H-}ASleep: boolean); function CheckScriptAndDependencySafe(AFilename: UTF8String; APythonVersion: integer): boolean; public constructor Create(APythonBin: string = DefaultPythonBin); function Run(AScriptFilename: UTF8String; APythonVersion: integer = 3): boolean; class function DefaultScriptDirectory: string; property OnOutputLine: TReceiveLineEvent read FOnOutputLine write FOnOutputLine; property OnError: TReceiveLineEvent read FOnError write FOnError; property OnCommand: TCommandEvent read FOnCommand write FOnCommand; property OnBusy: TNotifyEvent read FOnBusy write FOnBusy; property OnWarning: TWarningEvent read FOnWarning write FOnWarning; property PythonVersion: string read FPythonVersion; property PythonVersionMajor: integer read GetPythonVersionMajor; property ErrorText: UTF8String read FErrorText; property CheckScriptSecure: boolean read FCheckScriptSecure write FCheckScriptSecure; end; function GetPythonVersion(APythonBin: string = DefaultPythonBin): string; function GetScriptTitle(AFilename: string): string; function CheckPythonScriptSafe(AFilename: string; out ASafeModules, AUnsafeModules: TStringList): boolean; var CustomScriptDirectory: string; implementation uses process, UResourceStrings, Forms, UTranslation; var PythonVersionCache: record Bin: string; Version: string; end; function GetPythonVersion(APythonBin: string = DefaultPythonBin): string; const PythonVersionPrefix = 'Python '; var versionStr: string; begin if (PythonVersionCache.Bin <> APythonBin) or (PythonVersionCache.Version = '?') then begin RunCommand(APythonBin, ['-V'], versionStr, []); PythonVersionCache.Bin := APythonBin; if versionStr.StartsWith(PythonVersionPrefix) then PythonVersionCache.Version := trim(copy(versionStr,length(PythonVersionPrefix)+1, length(versionStr)-length(PythonVersionPrefix))) else PythonVersionCache.Version := '?'; end; result := PythonVersionCache.Version; end; function GetScriptTitle(AFilename: string): string; var t: textfile; header: string; matchLang: boolean; procedure RetrieveTitle(AText: string; ADefault: boolean; var title: string; out ALangMatch: boolean); var posCloseBracket: SizeInt; lang: String; begin If AText.StartsWith('#') then Delete(AText, 1,1); AText := AText.Trim; ALangMatch := false; if AText.StartsWith('(') then begin posCloseBracket := pos(')', AText); if posCloseBracket > 0 then begin lang := copy(AText, 2, posCloseBracket-2); delete(AText, 1, posCloseBracket); AText := AText.Trim; if lang = ActiveLanguage then ALangMatch:= true; end; end else begin if not ADefault then exit; if ActiveLanguage = DesignLanguage then ALangMatch:= true; end; if ALangMatch or ADefault then begin title := AText; title := StringReplace(title, ' >', '>', [rfReplaceAll]); title := StringReplace(title, '> ', '>', [rfReplaceAll]); end; end; procedure TranslateWithPoFile(var title: string); var elements: TStringList; i: integer; u: string; begin elements := TStringList.Create; try elements.Delimiter := '>'; elements.QuoteChar := #0; elements.DelimitedText := StringReplace(title, ' ', #160, [rfReplaceAll]); for i := 0 to elements.Count-1 do begin u := Trim(StringReplace(elements[i], #160, ' ', [rfReplaceAll])); elements[i] := DoTranslate('', u); end; finally title := elements.DelimitedText; elements.free; end; end; begin result := ''; assignFile(t, AFilename); reset(t); try readln(t, header); if header.StartsWith('#') then begin RetrieveTitle(header, true, result, matchLang); while not matchLang do begin readln(t, header); if header.StartsWith('#') then begin RetrieveTitle(header, false, result, matchLang); end else break; end; if not matchLang then TranslateWithPoFile(result); end; finally closefile(t); end; end; function CheckPythonScriptSafe(AFilename: string; out ASafeModules, AUnsafeModules: TStringList): boolean; function binarySearch(x: string; a: array of string): integer; var L, R, M: integer; // left, right, middle begin if Length(a)=0 then Exit(-1); L := Low (a); R := High(a); while (L <= R) do begin M := (L + R) div 2; if (x = a[M]) then Exit(M); // found x in a if (x > a[M]) then L := Succ(M) else R := Pred(M); end; Exit(-1) // did not found x in a end; function idOk(AId: string; var importCount: integer): boolean; const forbidden: array[0..6] of string = ('__import__', 'compile', 'eval', 'exec', 'getattr', 'globals', 'locals'); begin if AId = 'import' then inc(importCount); exit(binarySearch(AId, forbidden) = -1); end; const StartIdentifier = ['A'..'Z','a'..'z','_']; const ContinueIdentifier = ['A'..'Z','a'..'z','_','0'..'9']; const WhiteSpace = [' ', #9]; function importOk(const s: string; importCount: integer; previousBackslash: boolean): boolean; const ForbiddenModules: array[0..22] of string = ('builtins', // Provides direct access to all built-in identifiers of Python. 'code', // Facilities to implement interactive Python interpreters. 'codecs', // Core support for encoding and decoding text and binary data. 'ctypes', // Create and manipulate C-compatible data types in Python, and call functions in dynamic link libraries/shared libraries. 'ftplib', // Interface to the FTP protocol. 'gc', // Interface to the garbage collection facility for reference cycles. 'io', // Core tools for working with streams (core I/O operations). 'multiprocessing', // Process-based parallelism. 'os', // Interface to the operating system, including file and process operations. 'pathlib', // Object-oriented filesystem paths. 'poplib', // Client-side support for the POP3 protocol. 'pty', // Operations for handling the pseudo-terminal concept. 'runpy', // Locating and running Python programs using various modes of the `__main__` module. 'shutil', // High-level file operations, including copying and deletion. 'smtplib', // Client-side objects for the SMTP and ESMTP protocols. 'socket', // Low-level networking operations. 'subprocess', // Spawn additional processes, connect to their input/output/error pipes, and obtain their return codes. 'sys', // Access and set variables used or maintained by the Python interpreter. 'telnetlib', // Client-side support for the Telnet protocol. 'tempfile', // Generate temporary files and directories. 'threading', // Higher-level threading interfaces on top of the lower-level `_thread` module. 'wsgiref', // WSGI utility functions and reference implementation. 'xmlrpc' // XML-RPC server and client modules. ); const SafeModules: array[0..26] of string = ('PIL', // Python Imaging Library, for image processing. 'array', // Basic mutable array operations. 'ast', // Abstract Syntax Trees 'bisect', // Algorithms for manipulating sorted lists. 'calendar', // Functions for working with calendars and dates. 'collections', // Container datatypes like namedtuples and defaultdict. 'colorsys', // Color system conversions. 'copy', // Shallow and deep copy operations. 'csv', // Reading and writing CSV files. 'datetime', // Basic date and time types. 'decimal', // Fixed and floating point arithmetic using decimal notation. 'enum', // Enumerations in Python. 'fractions', // Rational numbers. 'functools', // Higher-order functions and operations on callable objects. 'hashlib', // Secure hash and message digest algorithms. 'itertools', // Functions for creating iterators for efficient looping. 'json', // Encoding and decoding JSON format. 'lazpaint', 'math', // Mathematical functions. 'platform', // Access to platform-specific attributes and functions. 'queue', // A multi-producer, multi-consumer queue. 'random', // Generate pseudo-random numbers. 'statistics', // Mathematical statistics functions. 'string', // Common string operations. 'time', // Time-related functions. 'tkinter', // Standard GUI library for Python. 'uuid'); // UUID objects procedure SkipSpaces(var idx: integer); begin while (idx <= length(s)) and (s[idx] in WhiteSpace) do inc(idx); end; function GetId(var idx: integer): string; var idxEnd: integer; begin if (idx > length(s)) or not (s[idx] in StartIdentifier) then exit(''); idxEnd := idx+1; while (idxEnd <= length(s)) and (s[idxEnd] in ContinueIdentifier) do inc(idxEnd); result := copy(s, idx, idxEnd-idx); idx := idxEnd; end; function SkipAs(var idx: integer): boolean; var subId: String; begin SkipSpaces(idx); if (idx > length(s)) or (s[idx] = '#') then exit(true); subId := GetId(idx); if subId = 'as' then begin SkipSpaces(idx); subId := GetId(idx); if subId = '' then exit(false); // syntax error end; exit(true); end; function ParseModuleName(var idx: integer; out AModuleName: string; out AIsSafe: boolean): boolean; var subId: String; begin SkipSpaces(idx); AIsSafe := false; AModuleName := GetId(idx); if AModuleName = '' then exit(false); // syntax error // check if module is allowed if binarySearch(AModuleName, ForbiddenModules) <> -1 then exit(false); AIsSafe := binarySearch(AModuleName, SafeModules) <> -1; SkipSpaces(idx); // submodule while (idx <= length(s)) and (s[idx] = '.') do begin inc(idx); SkipSpaces(idx); subId := GetId(idx); if subId = '' then exit(false); // syntax error AModuleName += '.' + subId; SkipSpaces(idx); end; exit(true); end; procedure AddModule(AModuleName: string; AIsSafe: boolean); begin if not AIsSafe then begin if AUnsafeModules = nil then AUnsafeModules := TStringList.Create; if AUnsafeModules.IndexOf(AModuleName) = -1 then AUnsafeModules.Add(AModuleName); end else begin if ASafeModules = nil then ASafeModules := TStringList.Create; if ASafeModules.IndexOf(AModuleName) = -1 then ASafeModules.Add(AModuleName); end; end; var idx: integer; fromClause: boolean; moduleName, subId: string; isSafe: boolean; begin if importCount <> 1 then exit(false); // syntax error if s.StartsWith('from ') then begin idx := length('from ') + 1; fromClause := true; end else if s.StartsWith('import ') then begin if previousBackslash then exit(false); // could be an exploit idx := length('import ') + 1; fromClause := false; end else exit(false); // syntax error if not ParseModuleName(idx, moduleName, isSafe) then exit(false); if fromClause then begin subId := GetId(idx); if subId <> 'import' then exit(false); // syntax error repeat SkipSpaces(idx); subId := GetId(idx); if subId = '' then exit(false); // syntax error AddModule(moduleName+'.'+subId, isSafe); if not SkipAs(idx) then exit(false); SkipSpaces(idx); if (idx <= length(s)) and (s[idx] = ',') then inc(idx) else break; until false; end else begin repeat AddModule(moduleName, isSafe); if not SkipAs(idx) then exit(false); SkipSpaces(idx); if (idx <= length(s)) and (s[idx] = ',') then begin inc(idx); if not ParseModuleName(idx, moduleName, isSafe) then exit(false); end else break; until false; end; if (idx <= length(s)) and (s[idx] <> '#') then // expect end of line exit(false); // syntax error exit(true); end; function lineOk(const s: string; previousBackslash: boolean): boolean; var startId, i: integer; importCount: integer; begin startId := -1; importCount := 0; for i := 1 to length(s) do begin // check identifier boundaries if (startId = -1) and (s[i] in StartIdentifier) then begin startId := i; end else if (startId <> -1) and not (s[i] in ContinueIdentifier) then begin if not idOk(copy(s, startId, i-startId), importCount) then exit(false); startId := -1; end; end; if (startId <> -1) and not idOk(copy(s, startId, length(s)-startId+1), importCount) then exit(false); if (importCount > 0) and not importOk(s, importCount, previousBackslash) then exit(false); exit(true); end; var t: textfile; s: string; previousBackslash: boolean; begin ASafeModules := nil; AUnsafeModules := nil; assignFile(t, AFilename); reset(t); previousBackslash := false; while not eof(t) do begin readln(t, s); s := trim(s); if not lineOk(s, previousBackslash) then exit(false); previousBackslash := s.EndsWith('\'); end; closefile(t); exit(true); end; { TPythonScript } procedure TPythonScript.PythonOutput(ALine: RawByteString); var idxParam, cmdPos: SizeInt; command, param, finalLine: RawByteString; commandRes: UTF8String; i, curDisplayPos, maxDisplayLen: Integer; displayedLine: RawByteString; begin if FFirstOutput then begin if ALine <> 'LazPaint script'#9 then raise exception.Create(rsNotLazPaintScript) else begin FFirstOutput:= false; if Assigned(FPythonSend) then FPythonSend(chr(27)+'LazPaint') else raise exception.Create('"Send" callback not defined'); end; end; cmdPos := pos(#27, ALine); if (cmdPos > 0) then begin FLinePrefix += copy(ALine, 1, cmdPos-1); delete(ALine, 1, cmdPos-1); idxParam := Pos(#29, ALine); param := ''; if idxParam = 0 then command := copy(ALine,2,length(ALine)-1) else begin command := copy(ALine,2,idxParam-2); param := copy(ALine,idxParam+1,length(ALine)-(idxParam+1)+1); end; if command<>'' then begin if command[length(command)] = '?' then begin delete(command, length(command), 1); if Assigned(FOnCommand) then FOnCommand(self, command, param, commandRes) else commandRes := ''; if Assigned(FPythonSend) then FPythonSend(commandRes); end else begin if Assigned(FOnCommand) then FOnCommand(self, command, param, commandRes); end; end; end else begin if Assigned(FOnOutputLine) then begin finalLine := FLinePrefix+ALine; displayedLine := ''; setlength(displayedLine, 80); curDisplayPos := 1; maxDisplayLen := 0; for i := 1 to length(finalLine) do begin if finalLine[i] = #13 then curDisplayPos := 1 else if finalLine[i] = #8 then begin if curDisplayPos > 1 then dec(curDisplayPos); end else begin if curDisplayPos > length(displayedLine) then setlength(displayedLine, length(displayedLine)*2); displayedLine[curDisplayPos] := finalLine[i]; if curDisplayPos > maxDisplayLen then maxDisplayLen := curDisplayPos; inc(curDisplayPos); end; end; setlength(displayedLine, maxDisplayLen); FOnOutputLine(self, displayedLine); end; FLinePrefix := ''; end; end; procedure TPythonScript.PythonBusy(var ASleep: boolean); begin if Assigned(FOnBusy) then FOnBusy(self); end; function TPythonScript.CheckScriptAndDependencySafe(AFilename: UTF8String; APythonVersion: integer): boolean; var filesToCheck: TStringList; procedure AddModuleToCheck(AModuleName: UTF8String; ABasePath: UTF8String); var fullPath, moduleFilename: string; begin fullPath := ConcatPaths([ABasePath, StringReplace(AModuleName, '.', PathDelim, [rfReplaceAll])]); moduleFilename := fullPath+'.py'; if (filesToCheck.IndexOf(moduleFilename) = -1) and FileExists(moduleFilename) then filesToCheck.Add(moduleFilename) else begin moduleFilename := fullPath+'\__init__.py'; if (filesToCheck.IndexOf(moduleFilename) = -1) and FileExists(moduleFilename) then filesToCheck.Add(moduleFilename); end; end; var safeModules, unsafeModules, allUnsafeModules: TStringList; proceed: boolean; curFile, i: integer; curPath: string; begin allUnsafeModules := TStringList.Create; allUnsafeModules.Sorted := true; allUnsafeModules.Duplicates:= dupIgnore; filesToCheck := TStringList.Create; filesToCheck.Add(AFilename); curFile := 0; curPath := ExtractFilePath(AFilename); while curFile < filesToCheck.Count do begin if not CheckPythonScriptSafe(filesToCheck[curFile], safeModules, unsafeModules) then begin safeModules.Free; unsafeModules.Free; raise exception.Create(StringReplace(rsScriptNotSafe, '%1', filesToCheck[curFile], [])); end; if Assigned(unsafeModules) then begin for i := 0 to unsafeModules.Count-1 do begin AddModuleToCheck(unsafeModules[i], curPath); allUnsafeModules.Add(unsafeModules[i]); end; end; if Assigned(safeModules) then begin for i := 0 to safeModules.Count-1 do AddModuleToCheck(safeModules[i], curPath); end; safeModules.Free; unsafeModules.Free; inc(curFile); end; filesToCheck.Free; if allUnsafeModules.Count > 0 then begin proceed := true; if Assigned(OnWarning) then begin OnWarning(self, StringReplace(rsSureToRunUnsafeScript, '%1', allUnsafeModules.CommaText, []), proceed); end; allUnsafeModules.Free; if not proceed then exit(false); end else allUnsafeModules.Free; if PythonVersionMajor <> APythonVersion then raise exception.Create( StringReplace( StringReplace(rsPythonUnexpectedVersion, '%1',inttostr(APythonVersion),[]), '%2',inttostr(PythonVersionMajor),[]) + #9 + rsDownload + #9 + 'https://www.python.org'); exit(true); end; constructor TPythonScript.Create(APythonBin: string); begin FPythonBin := APythonBin; {$IFDEF DARWIN} if (FPythonBin = 'python3') and FileExists(UserPythonBin) then FPythonBin:= UserPythonBin; {$ENDIF} FPythonVersion:= GetPythonVersion(FPythonBin); end; procedure TPythonScript.PythonError(ALine: RawByteString); begin if Assigned(FOnError) then FOnError(self, ALine) else FErrorText += ALine+LineEnding; end; function TPythonScript.GetPythonVersionMajor: integer; var posDot: SizeInt; {%H-}errPos: integer; begin posDot := pos('.',PythonVersion); if posDot = 0 then result := 0 else val(copy(PythonVersion,1,posDot-1), result, errPos); end; function TPythonScript.Run(AScriptFilename: UTF8String; APythonVersion: integer): boolean; var exitCode: integer; begin result := false; if CheckScriptSecure and not CheckScriptAndDependencySafe(AScriptFilename, APythonVersion) then exit; FLinePrefix := ''; FFirstOutput:= true; AutomationEnvironment.Values['PYTHONPATH'] := DefaultScriptDirectory; AutomationEnvironment.Values['PYTHONIOENCODING'] := 'utf-8'; try exitCode := RunProcessAutomation(FPythonBin, ['-u', AScriptFilename], FPythonSend, @PythonOutput, @PythonError, @PythonBusy); finally AutomationEnvironment.Clear; end; FPythonSend := nil; result := exitCode = 0; end; class function TPythonScript.DefaultScriptDirectory: string; begin if CustomScriptDirectory<>'' then result := CustomScriptDirectory else result := GetResourcePath('scripts'); end; end.