chromium/base/files/os_validation_win_unittest.cc

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include <windows.h>

#include <shlobj.h>

#include <iterator>
#include <memory>
#include <string>
#include <string_view>
#include <tuple>

#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/memory/ptr_util.h"
#include "base/strings/string_util.h"
#include "base/win/scoped_handle.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/abseil-cpp/absl/cleanup/cleanup.h"

#define FPL FILE_PATH_LITERAL

namespace base {

// A basic test harness that creates a temporary directory during test case
// setup and deletes it during teardown.
class OsValidationTest : public ::testing::Test {
 protected:
  // ::testing::Test:
  static void SetUpTestSuite() {
    temp_dir_ = std::make_unique<ScopedTempDir>().release();
    ASSERT_TRUE(temp_dir_->CreateUniqueTempDir());
  }

  static void TearDownTestSuite() {
    // Explicitly delete the dir to catch any deletion errors.
    ASSERT_TRUE(temp_dir_->Delete());
    auto temp_dir = base::WrapUnique(temp_dir_);
    temp_dir_ = nullptr;
  }

  // Returns the path to the test's temporary directory.
  static const FilePath& temp_path() { return temp_dir_->GetPath(); }

 private:
  static ScopedTempDir* temp_dir_;
};

// static
ScopedTempDir* OsValidationTest::temp_dir_ = nullptr;

// A test harness for exhaustively evaluating the conditions under which an open
// file may be operated on. Template parameters are used to turn off or on
// various bits in the access rights and sharing mode bitfields. These template
// parameters are:
// - The standard access right bits (except for WRITE_OWNER, which requires
//   admin rights): SYNCHRONIZE, WRITE_DAC, READ_CONTROL, DELETE.
// - Generic file access rights: FILE_GENERIC_READ, FILE_GENERIC_WRITE,
//                               FILE_EXECUTE.
// - The sharing bits: FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_SHARE_DELETE.
class OpenFileTest : public OsValidationTest,
                     public ::testing::WithParamInterface<
                         std::tuple<std::tuple<DWORD, DWORD, DWORD, DWORD>,
                                    std::tuple<DWORD, DWORD, DWORD>,
                                    std::tuple<DWORD, DWORD, DWORD>>> {
 protected:
  OpenFileTest() = default;
  OpenFileTest(const OpenFileTest&) = delete;
  OpenFileTest& operator=(const OpenFileTest&) = delete;

  // Returns a dwDesiredAccess bitmask for use with CreateFileW containing the
  // test's access right bits.
  static DWORD GetAccess() {
    // Extract the two tuples of standard and generic file rights.
    std::tuple<DWORD, DWORD, DWORD, DWORD> standard_rights;
    std::tuple<DWORD, DWORD, DWORD> generic_rights;
    std::tie(standard_rights, generic_rights, std::ignore) = GetParam();

    // Extract the five standard rights bits.
    auto [synchronize_bit, write_dac_bit, read_control_bit, delete_bit] =
        standard_rights;

    // Extract the three generic file rights masks.
    auto [file_generic_read_bits, file_generic_write_bits,
          file_generic_execute_bits] = generic_rights;

    // Combine and return the desired access rights.
    return synchronize_bit | write_dac_bit | read_control_bit | delete_bit |
           file_generic_read_bits | file_generic_write_bits |
           file_generic_execute_bits;
  }

  // Returns a dwShareMode bitmask for use with CreateFileW containing the
  // tests's share mode bits.
  static DWORD GetShareMode() {
    // Extract the tuple of sharing mode bits.
    std::tuple<DWORD, DWORD, DWORD> sharing_bits;
    std::tie(std::ignore, std::ignore, sharing_bits) = GetParam();

    // Extract the sharing mode bits.
    auto [share_read_bit, share_write_bit, share_delete_bit] = sharing_bits;

    // Combine and return the sharing mode.
    return share_read_bit | share_write_bit | share_delete_bit;
  }

  // Appends string representation of the access rights bits present in |access|
  // to |result|.
  static void AppendAccessString(DWORD access, std::string* result) {
#define ENTRY(a) \
  { a, #a }
    static constexpr BitAndName kBitNames[] = {
        // The standard access rights:
        ENTRY(SYNCHRONIZE),
        ENTRY(WRITE_OWNER),
        ENTRY(WRITE_DAC),
        ENTRY(READ_CONTROL),
        ENTRY(DELETE),
        // The file-specific access rights:
        ENTRY(FILE_WRITE_ATTRIBUTES),
        ENTRY(FILE_READ_ATTRIBUTES),
        ENTRY(FILE_EXECUTE),
        ENTRY(FILE_WRITE_EA),
        ENTRY(FILE_READ_EA),
        ENTRY(FILE_APPEND_DATA),
        ENTRY(FILE_WRITE_DATA),
        ENTRY(FILE_READ_DATA),
    };
#undef ENTRY
    ASSERT_NO_FATAL_FAILURE(AppendBitsToString(access, std::begin(kBitNames),
                                               std::end(kBitNames), result));
  }

  // Appends a string representation of the sharing mode bits present in
  // |share_mode| to |result|.
  static void AppendShareModeString(DWORD share_mode, std::string* result) {
#define ENTRY(a) \
  { a, #a }
    static constexpr BitAndName kBitNames[] = {
        ENTRY(FILE_SHARE_DELETE),
        ENTRY(FILE_SHARE_WRITE),
        ENTRY(FILE_SHARE_READ),
    };
#undef ENTRY
    ASSERT_NO_FATAL_FAILURE(AppendBitsToString(
        share_mode, std::begin(kBitNames), std::end(kBitNames), result));
  }

  // Returns true if we expect that a file opened with |access| access rights
  // and |share_mode| sharing can be moved via MoveFileEx, and can be deleted
  // via DeleteFile so long as it is not mapped into a process.
  static bool CanMoveFile(DWORD access, DWORD share_mode) {
    // A file can be moved as long as it is opened with FILE_SHARE_DELETE or
    // if nothing beyond the standard access rights (save DELETE) has been
    // requested. It can be deleted under those same circumstances as long as
    // it has not been mapped into a process.
    constexpr DWORD kStandardNoDelete = STANDARD_RIGHTS_ALL & ~DELETE;
    return ((share_mode & FILE_SHARE_DELETE) != 0) ||
           ((access & ~kStandardNoDelete) == 0);
  }

  // OsValidationTest:
  void SetUp() override {
    OsValidationTest::SetUp();

    // Determine the desired access and share mode for this test.
    access_ = GetAccess();
    share_mode_ = GetShareMode();

    // Make a ScopedTrace instance for comprehensible output.
    std::string access_string;
    ASSERT_NO_FATAL_FAILURE(AppendAccessString(access_, &access_string));
    std::string share_mode_string;
    ASSERT_NO_FATAL_FAILURE(
        AppendShareModeString(share_mode_, &share_mode_string));
    scoped_trace_ = std::make_unique<::testing::ScopedTrace>(
        __FILE__, __LINE__, access_string + ", " + share_mode_string);

    // Make a copy of imm32.dll in the temp dir for fiddling.
    ASSERT_TRUE(CreateTemporaryFileInDir(temp_path(), &temp_file_path_));
    ASSERT_TRUE(CopyFile(FilePath(FPL("c:\\windows\\system32\\imm32.dll")),
                         temp_file_path_));

    // Open the file
    file_handle_.Set(::CreateFileW(temp_file_path_.value().c_str(), access_,
                                   share_mode_, nullptr, OPEN_EXISTING,
                                   FILE_ATTRIBUTE_NORMAL, nullptr));
    ASSERT_TRUE(file_handle_.is_valid()) << ::GetLastError();

    // Get a second unique name in the temp dir to which the file might be
    // moved.
    temp_file_dest_path_ = temp_file_path_.InsertBeforeExtension(FPL("bla"));
  }

  void TearDown() override {
    file_handle_.Close();

    // Manually delete the temp files since the temp dir is reused across tests.
    ASSERT_TRUE(DeleteFile(temp_file_path_));
    ASSERT_TRUE(DeleteFile(temp_file_dest_path_));
  }

  DWORD access() const { return access_; }
  DWORD share_mode() const { return share_mode_; }
  const FilePath& temp_file_path() const { return temp_file_path_; }
  const FilePath& temp_file_dest_path() const { return temp_file_dest_path_; }
  HANDLE file_handle() const { return file_handle_.get(); }

 private:
  struct BitAndName {
    DWORD bit;
    std::string_view name;
  };

  // Appends the names of the bits present in |bitfield| to |result| based on
  // the array of bit-to-name mappings bounded by |bits_begin| and |bits_end|.
  static void AppendBitsToString(DWORD bitfield,
                                 const BitAndName* bits_begin,
                                 const BitAndName* bits_end,
                                 std::string* result) {
    while (bits_begin < bits_end) {
      const BitAndName& bit_name = *bits_begin;
      if (bitfield & bit_name.bit) {
        if (!result->empty())
          result->append(" | ");
        result->append(bit_name.name);
        bitfield &= ~bit_name.bit;
      }
      ++bits_begin;
    }
    ASSERT_EQ(bitfield, DWORD{0});
  }

  DWORD access_ = 0;
  DWORD share_mode_ = 0;
  std::unique_ptr<::testing::ScopedTrace> scoped_trace_;
  FilePath temp_file_path_;
  FilePath temp_file_dest_path_;
  win::ScopedHandle file_handle_;
};

// Tests that an opened but not mapped file can be deleted as expected.
TEST_P(OpenFileTest, DeleteFile) {
  if (CanMoveFile(access(), share_mode())) {
    EXPECT_NE(::DeleteFileW(temp_file_path().value().c_str()), 0)
        << "Last error code: " << ::GetLastError();
  } else {
    EXPECT_EQ(::DeleteFileW(temp_file_path().value().c_str()), 0);
  }
}

// Tests that an opened file can be moved as expected.
TEST_P(OpenFileTest, MoveFileEx) {
  if (CanMoveFile(access(), share_mode())) {
    EXPECT_NE(::MoveFileExW(temp_file_path().value().c_str(),
                            temp_file_dest_path().value().c_str(), 0),
              0)
        << "Last error code: " << ::GetLastError();
  } else {
    EXPECT_EQ(::MoveFileExW(temp_file_path().value().c_str(),
                            temp_file_dest_path().value().c_str(), 0),
              0);
  }
}

// Tests that an open file cannot be moved after it has been marked for
// deletion.
TEST_P(OpenFileTest, DeleteThenMove) {
  // Don't test combinations that cannot be deleted.
  if (!CanMoveFile(access(), share_mode()))
    return;
  ASSERT_NE(::DeleteFileW(temp_file_path().value().c_str()), 0)
      << "Last error code: " << ::GetLastError();
  // Move fails with ERROR_ACCESS_DENIED (STATUS_DELETE_PENDING under the
  // covers).
  EXPECT_EQ(::MoveFileExW(temp_file_path().value().c_str(),
                          temp_file_dest_path().value().c_str(), 0),
            0);
}

// Tests that an open file that is mapped into memory can be moved but not
// deleted.
TEST_P(OpenFileTest, MapThenDelete) {
  // There is nothing to test if the file can't be read.
  if (!(access() & FILE_READ_DATA))
    return;

  // Pick the protection option that matches the access rights used to open the
  // file.
  static constexpr struct {
    DWORD access_bits;
    DWORD protection;
  } kAccessToProtection[] = {
      // Sorted from most- to least-bits used for logic below.
      {FILE_READ_DATA | FILE_WRITE_DATA | FILE_EXECUTE, PAGE_EXECUTE_READWRITE},
      {FILE_READ_DATA | FILE_WRITE_DATA, PAGE_READWRITE},
      {FILE_READ_DATA | FILE_EXECUTE, PAGE_EXECUTE_READ},
      {FILE_READ_DATA, PAGE_READONLY},
  };

  DWORD protection = 0;
  for (const auto& scan : kAccessToProtection) {
    if ((access() & scan.access_bits) == scan.access_bits) {
      protection = scan.protection;
      break;
    }
  }
  ASSERT_NE(protection, DWORD{0});

  win::ScopedHandle mapping(::CreateFileMappingA(
      file_handle(), nullptr, protection | SEC_IMAGE, 0, 0, nullptr));
  auto result = ::GetLastError();
  ASSERT_TRUE(mapping.is_valid()) << result;

  auto* view = ::MapViewOfFile(mapping.get(), FILE_MAP_READ, 0, 0, 0);
  result = ::GetLastError();
  ASSERT_NE(view, nullptr) << result;
  absl::Cleanup unmapper = [view] { ::UnmapViewOfFile(view); };

  // Mapped files cannot be deleted under any circumstances.
  EXPECT_EQ(::DeleteFileW(temp_file_path().value().c_str()), 0);

  // But can still be moved under the same conditions as if it weren't mapped.
  if (CanMoveFile(access(), share_mode())) {
    EXPECT_NE(::MoveFileExW(temp_file_path().value().c_str(),
                            temp_file_dest_path().value().c_str(), 0),
              0)
        << "Last error code: " << ::GetLastError();
  } else {
    EXPECT_EQ(::MoveFileExW(temp_file_path().value().c_str(),
                            temp_file_dest_path().value().c_str(), 0),
              0);
  }
}

// These tests are intentionally disabled by default. They were created as an
// educational tool to understand the restrictions on moving and deleting files
// on Windows. There is every expectation that once they pass, they will always
// pass. It might be interesting to run them manually on new versions of the OS,
// but there is no need to run them on every try/CQ run. Here is one possible
// way to run them all locally:
//
// base_unittests.exe --single-process-tests --gtest_also_run_disabled_tests \
//     --gtest_filter=*OpenFileTest*
INSTANTIATE_TEST_SUITE_P(
    DISABLED_Test,
    OpenFileTest,
    ::testing::Combine(
        // Standard access rights except for WRITE_OWNER, which requires admin.
        ::testing::Combine(::testing::Values(0, SYNCHRONIZE),
                           ::testing::Values(0, WRITE_DAC),
                           ::testing::Values(0, READ_CONTROL),
                           ::testing::Values(0, DELETE)),
        // Generic file access rights.
        ::testing::Combine(::testing::Values(0, FILE_GENERIC_READ),
                           ::testing::Values(0, FILE_GENERIC_WRITE),
                           ::testing::Values(0, FILE_GENERIC_EXECUTE)),
        // File sharing mode.
        ::testing::Combine(::testing::Values(0, FILE_SHARE_READ),
                           ::testing::Values(0, FILE_SHARE_WRITE),
                           ::testing::Values(0, FILE_SHARE_DELETE))));

}  // namespace base