| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682 |
- // 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.
|