пятница, 14 января 2011 г.

Как WinAPI сообщает об ошибках

Это перевод The Way WinAPI Shows Errors. Автор: Christian Wimmer.

Я увидел интересный ответ в обсуждении на форуме, в котором я участвовал. Вопрос был не на тему этого поста, но ответ с кодом был достаточно интересным, чтобы написать эту статью. Код был написан на Delphi, но, очевидно, писался в стиле простого C (даже с использованием goto!).

Однако сейчас я хочу поговорить о том, как WinAPI обычно возвращает ошибки, и почему код в ответе - не самый удачный способ.

Тот код выглядел не в точности так. Я просто выделил из него существенные части для вашего удобства (исходный код напоминал C ещё больше, чем этот пример):
function IsUserMemberOf(const Group : PSID) : BOOL;
var
  hToken : HANDLE;
begin
  result := false;
  // получаем Token и сохраняем в hToken ... (короткий вариант)
  if not OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY or TOKEN_DUPLICATE, hToken) then
    goto Exit;

  // дополнительные действия для Vista и много-много gotos

  if not CheckTokenMembership(hToken, Group, @result) then
  begin
    goto Exit_1;
  end;

:Exit_1
  CloseHandle(hToken);
  goto Exit;
:Exit
end; 
В чём проблема с возвращаемым значением этой функции? Во-первых, возвращаемое значение каждой WinAPI функции проверяется и функция возвращает управление, если вызываемая WinAPI функция возвращает ошибку. Это - очень хорошо, потому что я видел кучу кода, который ничем таким не озаботились. Но здесь есть другая проблема. Существует два возможных набора результатов, которые мы бы хотели сообщить нашему вызывающему:
  • Является ли пользователь членом группы. Это, очевидно, двоичное значение (скажем, True/False).
  • Произошла ли ошибка. Аналогично, это двоичное значение (True/False).
Диапазон возвращаемых значений вашей функции варьируется от False(0) до True(1). Функция проецирует два набора с разным смыслом в один набор - результат функции. Если один из вызовов WinAPI завершиться неудачей, то функция выйдет и вернёт False. Очевидно, что значение False используется для двух совершенно разных по смыслу результатов. Одно из которых говорит вызывающему, что пользователь не входит в группу, а другое - что функция почему-то завершилась с ошибкой. А мы соединили эти два смысла в одно значение. Т.е. мы потеряли информацию! Теперь мы не можем отличить один случай от другого без дополнительной (мета) информации. Всегда думайте об этом, когда вы проектируете заголовок функции. Но, конечно же, эта информация есть у нас в другом месте. В самом WinAPI. GetLastError даст вам значение ошибки, которое (я надеюсь) позволит нам решить нашу проблему. Так что всё нормально? Хотелось бы верить! Посмотрите на это:
function GetMyUserName : String;
var
  nSize : Cardinal;
  p : PChar;
begin
  nSize := 0;
  if not GetUserName(nil, nSize) and (GetLastError() = ERROR_INSUFFICIENT_BUFFER) then
  begin  
    GetMem(p, nSize * sizeof(CHAR));
    try
      if not GetUserName(p, nSize) then
        RaiseLastOSError;
      result := p;
    finally
      FreeMem(p);
    end;
  end
  else
    RaiseLastOSError;
end; 

begin
  ...
  xy := GetMyUserName();
  bIsMember := IsUserMemberOf(aGroup);
 
  if not bIsMember and (GetLastError() <> 0) then
    ОШИБКА //...обработка;
end;
Функция IsUserMemberOf вызывается после успешного вызова GetMyUserName, которая первым делом просит WinAPI-функцию GetUserName вернуть размер строки, содержащей имя пользователя. В этом случае GetLastError вернёт ERROR_INSUFFICIENT_BUFFER (122dec), которая говорит нам, что необходимо использовать больший блок памяти.

В итоге GetMyUserName вернёт нам имя пользователя (успешно). Предположим, что IsUserMemberOf также будет успешна, но при этом она говорит нам, что пользователь не входит в группу. Тогда возвращаемое значение будет False, а тот факт, что большинство WinAPI функций не изменяют значение LastError при успешном вызове, говорит нам, что GetLastError всё ещё будет равно ERROR_INSUFFICIENT_BUFFER (122dec): поэтому когда мы будем проверять bIsMember - мы попадём на ветку кода с обработкой ошибки, хотя никакой ошибки не было.

Как вы видите, даже хотя мы можем получить дополнительные сведения от GetLastError, но эта функция реализована так, что мы не можем воспользоваться этой информацией.

Если вы планируете написать такую функцию, я призываю вас не использовать техники языка C. Почему? Просто посмотрите как выглядит этот стиль:
function GetUserNameW(lpBuffer: LPWSTR; nSize: PDWORD): BOOL;
var
  pUserName: PWideChar;
  res: NTSTATUS;
  bRes: Boolean;
begin
  bRes := FALSE;
 
  if (nSize = nil) then
  begin
    SetLastError(ERROR_INVALID_PARAMETER);
    goto Exit;
  end;
 
  if (lpBuffer = NIL) or (nSize^ <= InternalGetMinUserNameLength()) then
  begin
    nSize^ := InternalGetMinUserNameLength();
 
    SetLastError(ERROR_INSUFFICIENT_BUFFER);
    goto Exit;
  end;
 
  // Это не совсем нужно, но просто добавить сложности в пример
  pUserName := HLOCAL(LocalAlloc(LPTR, InternalGetMinUserNameLength() * sizeof(WIDECHAR));
  // TODO: проверить pUserName на nil: если nil - goto Exit
 
  res := NtGetUserName(pUserName, InternalGetMinUserNameLength() * sizeof(WIDECHAR));
  if NT_FAILED(res) then
  begin
    SetLastError(NtStatusToDosError(res));
    goto Exit_1;
  end;   
 
  if not StrCopyMemory(lpBuffer, pUserName) then 
    goto Exit_1;
 
  bRes := TRUE;
:Exit_1
  FreeMem(pUserName);
  goto Exit;
:Exit
  result := bRes; 
end;
Выглядит сложно, не так ли? Я не собирался писать точную реализацию GetUserName (положа руку на сердце - я её не знаю), а вместо этого показал возможную реализацию. Так что WinAPI использует результат функции только как указание на ошибку. False означает провал операции, а GetLastError сообщит вам, что именно пошло не так. True означает, что всё в порядке.

Нам действительно нужно всё это в Delphi? Вообще-то нет. Если вы хотите написать библиотеку (DLL), которую нужно вызывать из других языков, то вы можете использовать SetLastError. И вам придётся возвращать результат функции в var/out параметре (по ссылке). Конечно же, вы также можете использовать тип HRESULT в качестве возвращаемого значения.
function GetUserNameW(lpBuffer: LPWSTR; nSize: PDWORD): HRESULT;
Если же вы хотите написать просто функцию для Delphi, то вы можете использовать исключения.
procedure GetUserNameW(Name: PWideChar; var nSize: DWORD);
Большой плюс - что вы можете использовать функцию как функцию, поскольку результат функции теперь может прямо указывать на результат операции, а не на признак ошибки.
function GetUserNameW(Name: PWideChar; var nSize: DWORD) : PWideChar;
begin
  if (nSize = nil) then
  begin
    SetLastError(ERROR_INVALID_PARAMETER);
    RaiseLastOsError;
  end;
  ... // не буду копировать весь код - идею вы уловили
end;
Как вы можете видеть, мы вызываем RaiseLastOsError, которая возбуждает исключение EOsError. Оно содержит код от GetLastError в своём свойстве ErrorCode. Мы даже можем создать свой собственный класс исключения (к примеру, JWSCL использует EJwsclWinCallFailedException), и вообще убрать вызов SetLastError! И, конечно же, вы можете использовать родной строковый тип Delphi, что сделает эту функцию очень простой в использовании.
function GetUserName() : String;
Функция возвращает строку. Она не возвращает пустой строки при ошибке, как делали те реализации, что мы нашли в интернете. Иначе у нас была бы та же самая проблема с двумя наборами значений, спроецированными на одно.
function IsUserMemberOf(const Group : PSID) : Boolean;
var
  hToken : HANDLE;
begin
  result := false;
  // получаем Token и сохраняем в hToken ... (короткий вариант)
  if not OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY or TOKEN_DUPLICATE, hToken) then
    RaiseLastOsError;
 
  // дополнительные действия для Vista и много-много RaiseLastOsError;
 
  if not CheckTokenMembership(hToken, Group, @result) then
    RaiseLastOsError;
end;
Улучшенный вариант этой функции есть в JEDI Component Library (JCL) в файле JclSecurity.pas. Забудьте обо всех других реализациях!! Используйте эту:
function IsGroupMember(RelativeGroupID: DWORD): Boolean;
var
  psidAdmin: Pointer;
  Token: THandle;
  Count: DWORD;
  TokenInfo: PTokenGroups;
  HaveToken: Boolean;
  I: Integer;
const
  SE_GROUP_USE_FOR_DENY_ONLY = $00000010;
begin
  Result := not IsWinNT;
  if Result then // на Win9x и ME нет безопасности
    Exit;
  psidAdmin := nil;
  TokenInfo := nil;
  HaveToken := False;
  try
    Token := 0;
    HaveToken := OpenThreadToken(GetCurrentThread, TOKEN_QUERY, True, Token);
    if (not HaveToken) and (GetLastError = ERROR_NO_TOKEN) then
      HaveToken := OpenProcessToken(GetCurrentProcess, TOKEN_QUERY, Token);
    if HaveToken then
    begin
      {$IFDEF FPC}
      Win32Check(AllocateAndInitializeSid(SECURITY_NT_AUTHORITY, 2,
        SECURITY_BUILTIN_DOMAIN_RID, RelativeGroupID, 0, 0, 0, 0, 0, 0,
        psidAdmin));
      if GetTokenInformation(Token, TokenGroups, nil, 0, @Count) or
         (GetLastError <> ERROR_INSUFFICIENT_BUFFER) then
        RaiseLastOSError;
      TokenInfo := PTokenGroups(AllocMem(Count));
      Win32Check(GetTokenInformation(Token, TokenGroups, TokenInfo, Count, @Count));
      {$ELSE FPC}
      Win32Check(AllocateAndInitializeSid(SECURITY_NT_AUTHORITY, 2,
        SECURITY_BUILTIN_DOMAIN_RID, RelativeGroupID, 0, 0, 0, 0, 0, 0,
        psidAdmin));
      if GetTokenInformation(Token, TokenGroups, nil, 0, Count) or
         (GetLastError <> ERROR_INSUFFICIENT_BUFFER) then
        RaiseLastOSError;
      TokenInfo := PTokenGroups(AllocMem(Count));
      Win32Check(GetTokenInformation(Token, TokenGroups, TokenInfo, Count, Count));
      {$ENDIF FPC}
      for I := 0 to TokenInfo^.GroupCount - 1 do
      begin
        {$RANGECHECKS OFF} // Массив [0..0] - игнорируем ERangeError
        Result := EqualSid(psidAdmin, TokenInfo^.Groups[I].Sid);
        if Result then
        begin
          // Учесть denied ACE с SID администратора
          Result := TokenInfo^.Groups[I].Attributes and SE_GROUP_USE_FOR_DENY_ONLY
              <> SE_GROUP_USE_FOR_DENY_ONLY;
          Break;
        end;
        {$IFDEF RANGECHECKS_ON}
        {$RANGECHECKS ON}
        {$ENDIF RANGECHECKS_ON}
      end;
    end;
  finally
    if TokenInfo <> nil then
      FreeMem(TokenInfo);
    if HaveToken then
      CloseHandle(Token);
    if psidAdmin <> nil then
      FreeSid(psidAdmin);
  end;
end;
 
function IsAdministrator: Boolean;
begin
  Result := IsGroupMember(DOMAIN_ALIAS_RID_ADMINS);
end;
Это же можно сделать и на JWSCL:
uses
  JwaWindows,
  JwsclToken,
  JwsclKnownSid,
  JwsclUtils;
 
var
  Token : TJwSecurityToken;
begin
  JwInitWellKnownSIDs;
 
  Token := TJwSecurityToken.CreateTokenEffective(TOKEN_READ or TOKEN_QUERY or TOKEN_DUPLICATE);
  try
    Token.ConvertToImpersonatedToken(DEFAULT_IMPERSONATION_LEVEL, MAXIMUM_ALLOWED);
    if Token.CheckTokenMembership(JwAdministratorsSID) then
      // Пользователь - член группы Администраторы
  finally
    Token.Free;
  end;
end;

Комментариев нет:

Отправить комментарий

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.