AdaVirtus

 
  • Increase font size
  • Default font size
  • Decrease font size

Rozdział 9: Bezpieczna komunikacja

Spis treści

Program, który nie komunikuje się ze światem zewnętrznym w żaden sposób jest bezużyteczny, chociaż bardzo bezpieczny. Taki program mógłby znaleźć się w izolatce. Więzień w izolatce jest bezpieczny w tym sensie, że nie jest w stanie zranić innych ludzi, ale jednocześnie jest bezużyteczny dla społeczeństwa.

Tak więc program, by był użytecznym musi się komunikować. I jeśli program jest napisany w sposób wykluczający jakiekolwiek wewnętrzne niebezpieczeństwa, w znacznej mierze jest to daremne, jeśli nie jest bezpieczne jego komunikowanie się. Zatem, bezpieczeństwo w komunikacji jest ważne, gdyż dopiero wtedy można stwierdzić, że program jest w pełni skuteczny.

Być może warto przypomnieć rozróżnienie (z wprowadzenia) systemów safety-critical i security-critical: pierwszy rodzaj nie szkodzi światu, drugiemu nie szkodzi świat. Tak więc, komunikacja jest ostateczną podporą zarówno dla pierwszego, jak i drugiego rodzaju systemów.

9.1 Reprezentacja danych

Istotny aspekt comunikacji dotyczy związku abstrakcyjnego oprogramowania z realnym sprzętem. Większość języków zostawia ten rodzaj problemów implementacjom. Ada zaś, daje bardzo szczegółową kontrolę reprezentacji danych.

Na przykład, możemy chcieć, by dane w rekordzie miały określoną postać - być może celem spasowania z istniejącą strukturą pliku. Przypuśćmy, że rekord jest typu Key (rozdział Bezpieczne tworzenie obiektów):

type Key is limited
   record
      Issued: Date;
      Code: Integer;
   end record;

gdzie typ Date to:

type Date is
   record
      Day: Integer range 1 .. 31;
      Month: Integer range 1 .. 12;
      Year: Integer;
   end record;

Zakładamy, że używamy maszyny 32-bitowej, cztery bajty na słowo. Dzień i miesiąc łatwo mieszczą się w bajcie, a rok wymaga co najmniej 16 bitów. Zatem cała data może być zapakowana w pojedynczym słowie. Można to wyrazić następująco:

for Date use
   record
      Day at 0 range 0 .. 7;
      Month at 1 range 0 .. 7;
      Year at 2 range 0 .. 15;
   end record;

W przypadku typu Key, wymagana struktura to po prostu dwa słowa i w sposób nieunikniony implementacja wykorzysta reprezentację, której wymagamy. Ale lepiej się upewnić co do tego pisząc:

for Key use
   record
      Issued at 0 range 0 .. 31;
      Code at 1 range 0 .. 31;
   end record;

Innym przykładem może być typ Signal z rozdziału Bezpieczny system typów:

type Signal is (Danger, Caution, Clear);

Jeśli nie powiemy inaczej, kompilator zakoduje ten typ używając 0 dla Danger, 1 dla Caution i 2 dla Clear. Jednak w rzeczywistej aplikacji wartość sygnału może być zakodowana w inny sposób, np.: 1 dla Danger, 2 dla Caution i 4 dla Clear. Możemy poinstruować program, że ma skorzystać z takiego kodowania:

for Signal use (Danger => 1, Caution => 2, Clear => 4);

Dodatkowo, jeśli wartość The_Signal jest autonomicznie ładowana do programu w określonej pozycji jako pojedynczy bajt, również możemy nakazać kompilatorowi by zapewnił takie warunki:

for Signal'Size use 8;
for The_Signal'Address use 16#0ACE#;

Ostatni zapis umieszcza zmienną pod adresem 0ACE (wartość o podstawie 16).

9.2 Poprawność danych

Bardzo istotną kwestią w procesie programowania jest pewność, że dane otrzymywane ze świata zewnętrznego są poprawne. W większości przypadków możemy w prosty sposób zaprogramować różne kontrole przy pomocy zwykłych technik. Jednak czasami jest to nieporęczne.

Tak jest w przypadku typu Signal. Poinstruowaliśmy już kompilator, by odpowiednio traktował reprezentację wartości typu wyliczeniowego. Jeśli, na nieszczęście, zostanie otrzymana wartość, która nie ma rozpoznawalnego wzorca (być może są ustawione dwa bity, ponieważ zewnętrzne urządzenie jest w stanie przejściowym), nie możemy wyrazić testu w normalny sposób, ponieważ wartość taka przeniesie nas poza dziedzinę zbioru wartości typu Signal. W takiej sytuacji możemy zapisać:

if not The_Signal'Valid then ...

Innym sposobem jest wykorzystanie funkcji rodzajowej Unchecked_Conversion. Możemy odczytać wartość (np. jako bajt) i - jeśli jego wartość jest dopuszczalna - zamienić go na typ Signal. Do tego celu potrzebny będzie typ Byte i funkcja konwertująca:

type Byte is range 0 .. 255;
for Byte'Size use 8;
 
function Byte_To_Signal is new Unchecked_Conversion(Byte, Signal);

Po czym mozemy zapisać:

Raw_Signal: Byte;
for Raw_Signal'Address use 16#0ACE#;
The_Signal: Signal;
...
case Raw_Signal is
   when 1 | 2 | 4 =>
                              -- wartość surowa w porządku - zamień ją
      The_Signal := Byte_To_Signal(Raw_Signal);
      ...                     -- przetwarzanie prawidłówej wartości
   when others =>
      ...                     -- wartość surowa nieprawidłow
      ...                     -- podejmij działania korygujące
end case;

Pomysł polega na tym, że jeśli typ Byte to typ całkowity, możemy dla sprawdzenia zastosować w jego przypadku normalną arytmetykę. W skład działań korygujących może wchodzić rejestracja poszczególnych nieprawidłowych wartości etc.

Czytający winien zauważyć lukę w powyższym przykładzie, przy założeniu, że wartość jest naprawdę ładowana autonomicznie. Pomiędzy kontrolą a konwersją może pojawić się nowa wartość. Zaradzić temu można kopiując wartość do zmiennej lokalnej (przed kontrolą i dalszym przetwarzaniem).

9.3 Komunikacja z innymi językami

Większość dużych nowoczesnych systemów stworzona jest przy pomocy mieszanki języków. Podprogramy sterujące (tu: safety-critical) i obsługi danych wejściowych (ti: security-critical) krytyczne ze względów bezpieczeństwa mogłyby być zapisane w Adzie (a może w języku Spark), interfejs graficzny w C++,jakieś skomplikowane analizy matematyczne można napisać w Fortranie, zaś sterowniki urządzeń - w języku C.

Większość języków ma pewne predyspozycje do współpracy z innymi językami (C++, C), lecz często jest ten temat traktowany bardzo luźno. Unikalnym językiem pod tym względem jest Ada. Dostarcza dobrze określone w standardzie języka ogólne mechanizmy do łączenia programu adowego z innymi językami. Ada określa współpracę i wymianę danych z programami napisanymi w C, C++, Fortranie i COBOL-u. W szczegolności, Ada rozpoznaje reprezentację typów w tych językach (jak np. ułożenie matryc w Fortranie czy łańcuchów w C), dzięki czemu komunikacja pozostaje bezpieczna pod kątem typów.

W sytuacji, gdy korzystamy z wielu języków dobrym pomysłem jest, aby centralne miejsce zajmowała Ada. Zyskujemy dzięki temu pewność, że komunikacja pomiędzy innymi językami podlega regułom kontroli typów, a to dzięki stosowaniu podprogramów konwertujących Ady.

Ogólnie rzecz biorąc, komunikacja z innymi językami korzysta z pragm. Przypuśćmy, że mamy podprogram C nazwany next_byte i chcemy go wywoływać z programu adowego jako funkcję Next_Byte. Po prostu piszemy:

function Next_Byte return Byte;
pragma Import (C, Next_Byte);

Pragma instruuje kompilator, że ma zastosowania konwencja wywoływania języka C, a co za tym idzie nie ma treści tej funkcji w programie adowym. Pragma umożliwia stosowanie innych zewnętrznych nazw oraz nazw związanych z konsolidatorem.

Podobnie ma się sprawa w odwrotnej sytuacji. Chcemy, by zewnętrzny program w C miał możliwość wywoływania procedury adowej Action: czynimy nazwę procedury adowej dostępną dla świata zewnętrznego. Piszmy:

procedure Action (D: in Data);
pragma Export (C, Action);

Typy dostępowe odnoszące się do podprogramów zajmują poczesne miejsce w komunikacji pomiędzy językami. Dotyczy to szczególnie systemów interakcyjnych. Dla przykładu przypuśćmy, że chcemy by w momencie kliknięcia myszki interfejs GUI wywoływał procedurę Action. Załóżmy jeszcze, że mamy procedurę C mouse_click, pobierającą adres kodu wywoływanego, gdy zostanie wciśnięty któryś z przycisków myszki. Możemy w tej sytuacji zapisać:

type Response is access procedure (D: in Data);
pragma Convention (C, Response);
 
procedure Mouse_Click (P: in Response);
pragma Import (C, Mouse_Click);
 
procedure Action (D: in Data);
pragma Convention (C, Action);
...
Set_Click (Action'Access);

W tym wypadku nie uczyniliśmy nazwy procedury Action widocznej dla C, gdyż jest ona wywoływana pośrednio. Jednak zapewniliśmy prawidłowy proces ustalając odpowiednią konwencję nazewnictwa.

9.4 Strumienie

Potencjalne trudności wystąpią, gdy będziemy transmitować wartości różnych typów do i ze świata zewnętrznego. Wyprowadzanie danych jest proste: wiemy jakiego typu dane będą transmitowane i korzystamy z odpowiedniego formatu. Lecz wczytywanie danych stanowi problem, ponieważ najczęściej nie wiemy co otrzymujemy. Jeśli plik jest jednolity i zawiera wartości tylko jednego typu, do nas należy tylko upewnienie się, że "podpięliśmy" się pod odpowiednie wejście. Prawdziwe trudności pojawiają się, gdy w tym samym pliku są umieszczone wartości różnych typów. Ada ma kilka różnych mechanizmów plikowych. Niektóre z nich dotyczą plików jednorodnych, zawierających tylko dane całkowite albo tekstowe. Do obsługi plików niejednorodnych wykorzystujemy strumienie.

Dla przykładu przypuśćmy, że mamy do czynienia z mieszanką wartości typu Integer, Float i Signal. Wszystkie typy posiadają specjalne atrybuty 'Read i 'Write, służące do obsługi strumieni. Dane wyprowadzamy pisząc:

S: Stream_Access := Stream (The_File);
...
Integer'Write (S, An_Integer);
Float'Write (S, A_Float);
Signal'Write (S, A_Signal);

co skutkuje utworzeniem mieszanką wartości różnych typów w pliku The_File. Z braku miejsca nie możemy się tutaj za bardzo rozwodzić. Stąd tylko krótkie wyjaśnienie: S identyfikuje strumień powiązany z plikiem.

Wczytując dane musimy tylko odwrócić kolejność:

Signal'Read (S, A_Signal);
Float'Read (S, A_Float);
Integer'Read (S, An_Integer);

Jeśli kolejność wywoływania byłaby niewłaściwa zgłoszony byłby wyjątek Data_Error, gdyż Ada sprawdza czy wczytywane dane mają właściwy format.

W przypadku, gdy kolejność odczytu danych nie jest znana, tworzymy klasę pokrywającą wszystkie typy biorące udział w procesie transmisji danych. W przypadku tego prostego przykładu deklarujemy typ nadrzędny:

type Root is abstract tagged null record;

będący swego rodzaju opakowaniem oraz serię pojedynczych typów enkapsulujących rzeczywiste dane:

type S_Integer is new Root with
   record
      Value: Integer;
   end record;
 
type S_Float is new Root with
   record
      Value: Float;
   end record;
 
...

i tak dalej. Dane wyprowadzamy pisząc:

Root'Class'Output (S, (Root with An_Integer));
Root'Class'Output (S, (Root with A_Float));
Root'Class'Output (S, (Root with A_Signal));

Widizmy, że we wszystkich wywołaniach wykorzystana jest ta sama procedura. Na początku wyprowadzany jest znacznik danego typu, a następnie wywoływany dyspozycyjnie odpowiedni atrybut Write.

By wczytać dane piszemy:

Next_Item: Root'Class := Root'Class'Input (S);
...
Process (Next_Item);

Procedura Root'Class'Input wczytuje znacznik ze strumienia, a następnie dyspozycyjnie wywołuje atrybut Read, który czyta element, by w końcu przypisać go jako wartość początkową obiektu Next_Item. Po wczytaniu można dyspozycyjnie wywołać procedurę Process, która robi to co chcemy, na przykład przypisuje wartość do zmiennej odpowiedniego typu.

Aby uzyskać taki efekt wpierw deklarujemy abstrakcyjną procedurę dla typu bazowego:

procedure Process(X: in Root) is abstract;

a następnie już konkretne procedury:

overriding
procedure Process(X: S_Integer) is
begin
   An_Integer := X.Value;                   -- wyodrębnienie danej z opakowania
end Process;

Procedura Process mogłaby oczywiście robić cokolwiek z naszą daną.

Jest to trochę sztuczny przykład. Jednak pokazuje on, Ada może przetwarzać dane różnych typów w sposób, który zachowuje bezpieczny model typów.

9.5 Fabryki obiektów

W poprzedniej sekcji poznaliśmy sposób jak "zatrudnić" mechanizm strumieni do manipulowania danymi, których typy nie są znane do momentu wczytania tych danych. W Adzie 2005 dostępny jest również mechanizm wczytywania znacznika i utworzenie na jego bazie obiektu odpowiedniego typu.

Przypuśćmy, że manipulujemy obiektami geometrycznymi omawianymi w rozdziale Bezpieczne programowanie obiektowe. Mamy do czynienia z różnymi typami: Circle, Square, Triangle etc. Wszystkie typy są pochodną typu bazowego Geometry.Object. Wartości typów będą wczytywane z klawiatury. Dla okręgu oczekiwać będziemy dwóch współrzędnych i promienia. Dla trójkąta - dwóch współrzędnych i długości trzech boków itd. Można by zadeklarować funkcje do wczytywania tych wartości:

function Get_Object return Circle is
begin
   return C: Circle do
      Get(C.X_Coord); Get(C.Y_Coord); Get(C.Radius);
   end return;
end Get_Object;

Wewnętrzne wywołania Get są wywołaniami predefiniowanych procedur czytających proste wartości z klawiatury. Użytkownik będzie musiał podać określony kod informujący, jaki typ obiektu jest brany pod uwagę. Może wartości podawane dla okręgu będą poprzedzane łańcuchem "Okrąg".  Z tego powodu trzeba napisać prostą funkcję Get_String czytającą i zwracającą wczytany łańcuch.

Tak więc, wszystko co mamy do zrobienia to odczytać łańcuch kodu, a następnie wywołać odpowiednią procedurę Get_Object, tworzącą obiekt właściwego typu. Kluczem jest wykorzystanie predefiniowanej funkcji rodzajowej, która na podstawie dostarczonego znacznika zwraca obiekt odpowiedniego typu:

generic
   type T (<>) is abstract tagged limited private;
   with function Constructor return T is abstract;
function Generic_Dispatching_Constructor (The_Tag: Tag) return T'Class;

Ta funkcja rodzajowa ma dwa parametry rodzajowe. Pierwszy identyfikuje klasę typów (takich jak Geometry.Object, z którego to typu wywodzą się typy: Circle, Square, Triangle). Drugi to operacja dyspozycyjna tworząca obiekt określonego typu (taka jak funkcja Get_Object).

Teraz skonkretyzujemy funkcję rodzajową tak, by powstała funkcja-konstruktor obiektów geometrycznych:

function Make_Object is
   new Generic_Dispatching_Constructor(Object, Get_Object);

Wywołana funkcja Make_Object pobeira znacznik danego typu, po czym dyspozycyjnie wywołuje odpowiednią funkcję Get_Object, by w końcu zwrócić utworzoną wartość.

Utworzymy teraz zmienną typu dostępowego, dzięki której będziemy mogli odwoływać się do nowo utworzonego obiektu:

Object_Ptr: access Object'Class;

Jeżeli wartość znacznika znajdzie się już w zmiennej Object_Tag (typu Tag, który jest zdefiniowany w predefiniowanym pakiecie Ada.Tags - rodzajowa funkcja konstruktora również się tutaj znajduje), to możemy wywołać funkcję Make_Object:

Object_Ptr := new Object'(Make_Object(Object_Tag));

I tak otrzymaliśmy nowo utworzony obiekt (być może okrąg), którego wartości współrzędnych i promianie zostały odczytane z klawiatury.

Tak całkiem jeszcze nie skończyliśmy. Musimy dokonać konwersji łańcucha "Circle" identyfikującego typ na wartość znacznika wykorzystaną do wywołania dyspozycyjnego. Prosty sposób na to:

for Circle'External_Tag use "Circle";
for Triangle'External_Tag use "Triangle";

po czym możemy odczytać i dokonać konwersji łańcucha na wewnątrzną wartość znacznika przy pomocy:

Object_Tag: Tag := Internal_Tag (Get_String);

Oczywiście nie ma potrzeby deklarowania zmiennej Object_Tag, ponieważ możemy połączyć operacje w jednej instrukcji:

Object_Ptr := new Object' (Make_Object (Internal_Tag (Get_String));

Na końcu wypada stwierdzić, że powyższa dyskusja została nieco uproszczona. Rzeczywisty konstruktor ma parametr pomocniczy, który tutaj został pominięty.

Rozdział 8: Bezpieczny start programu Rozdział 10: Bezpieczna wielozadaniowość
Tekst oryginalny w języku angielskim - pdf2
Zmieniony: Czwartek, 25 Marzec 2010 11:08  

Dodaj swój komentarz

Imię:
Temat:
Komentarz: