chromium/chrome/browser/extensions/updater/local_extension_cache.cc

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

#include "chrome/browser/extensions/updater/local_extension_cache.h"

#include <string>
#include <string_view>

#include "base/files/file.h"
#include "base/files/file_enumerator.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/logging.h"
#include "base/strings/string_split.h"
#include "base/strings/string_util.h"
#include "base/system/sys_info.h"
#include "base/task/sequenced_task_runner.h"
#include "base/version.h"
#include "components/crx_file/id_util.h"
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"

namespace extensions {
namespace {

// File name extension for CRX files (not case sensitive).
const char kCRXFileExtension[] = ".crx";

// Delay between checks for flag file presence when waiting for the cache to
// become ready.
constexpr base::TimeDelta kCacheStatusPollingDelay = base::Seconds(1);

constexpr std::string_view kExtensionIdDelimiter = "\n";

}  // namespace

const char LocalExtensionCache::kCacheReadyFlagFileName[] = ".initialized";
const char LocalExtensionCache::kInvalidCacheIdsFileName[] = ".invalid_cache";

LocalExtensionCache::LocalExtensionCache(
    const base::FilePath& cache_dir,
    uint64_t max_cache_size,
    const base::TimeDelta& max_cache_age,
    const scoped_refptr<base::SequencedTaskRunner>& backend_task_runner)
    : cache_dir_(cache_dir),
      max_cache_size_(max_cache_size),
      min_cache_age_(base::Time::Now() - max_cache_age),
      backend_task_runner_(backend_task_runner),
      state_(kUninitialized),
      cache_status_polling_delay_(kCacheStatusPollingDelay) {}

LocalExtensionCache::~LocalExtensionCache() {
  if (state_ == kReady)
    CleanUp();
}

void LocalExtensionCache::Init(bool wait_for_cache_initialization,
                               base::OnceClosure callback) {
  DCHECK_EQ(state_, kUninitialized);

  state_ = kWaitInitialization;
  if (wait_for_cache_initialization)
    CheckCacheStatus(std::move(callback));
  else
    CheckCacheContents(std::move(callback));
}

void LocalExtensionCache::Shutdown(base::OnceClosure callback) {
  DCHECK_NE(state_, kShutdown);
  if (state_ == kReady)
    CleanUp();
  state_ = kShutdown;
  backend_task_runner_->PostTaskAndReply(FROM_HERE, base::DoNothing(),
                                         std::move(callback));
}

// static
LocalExtensionCache::CacheMap::iterator LocalExtensionCache::FindExtension(
    CacheMap& cache,
    const std::string& id,
    const std::string& expected_hash) {
  CacheHit hit = cache.equal_range(id);
  CacheMap::iterator empty_hash = cache.end();
  std::string hash = base::ToLowerASCII(expected_hash);
  for (CacheMap::iterator it = hit.first; it != hit.second; ++it) {
    if (expected_hash.empty() || it->second.expected_hash == hash) {
      return it;
    }
    if (it->second.expected_hash.empty()) {
      empty_hash = it;
    }
  }
  return empty_hash;
}

bool LocalExtensionCache::GetExtension(const std::string& id,
                                       const std::string& expected_hash,
                                       base::FilePath* file_path,
                                       std::string* version) {
  if (state_ != kReady)
    return false;

  CacheMap::iterator it = FindExtension(cached_extensions_, id, expected_hash);
  if (it == cached_extensions_.end())
    return false;

  if (file_path) {
    *file_path = it->second.file_path;

    // If caller is not interested in file_path, extension is not used.
    base::Time now = base::Time::Now();
    backend_task_runner_->PostTask(
        FROM_HERE, base::BindOnce(&LocalExtensionCache::BackendMarkFileUsed,
                                  it->second.file_path, now));
    it->second.last_used = now;
  }

  if (version)
    *version = it->second.version.GetString();

  return true;
}

bool LocalExtensionCache::ShouldRetryDownload(
    const std::string& id,
    const std::string& expected_hash) {
  if (state_ != kReady)
    return false;

  // Should retry download only if in the previous attempt the extension was
  // present in the cache and the installer process failed. After the removal,
  // the extension is freshly downloaded.
  CacheMap::iterator it = FindExtension(cached_extensions_, id, expected_hash);
  if (it == cached_extensions_.end())
    return false;

  return true;
}

// static
bool LocalExtensionCache::NewerOrSame(const CacheMap::iterator& entry,
                                      const base::Version& version,
                                      const std::string& expected_hash,
                                      int* compare) {
  const base::Version& prev_version = entry->second.version;
  int cmp = version.CompareTo(prev_version);

  if (compare)
    *compare = cmp;

  // Cache entry is newer if its version is greater or same, and in the latter
  // case we will prefer the existing one if we are trying to add an
  // unhashed file, or we already have a hashed file in cache.
  return (cmp < 0 || (cmp == 0 && (expected_hash.empty() ||
                                   !entry->second.expected_hash.empty())));
}

void LocalExtensionCache::PutExtension(const std::string& id,
                                       const std::string& expected_hash,
                                       const base::FilePath& file_path,
                                       const base::Version& version,
                                       PutExtensionCallback callback) {
  if (state_ != kReady) {
    std::move(callback).Run(file_path, true);
    return;
  }

  if (!version.IsValid()) {
    LOG(ERROR) << "Extension " << id << " has bad version " << version;
    std::move(callback).Run(file_path, true);
    return;
  }

  CacheMap::iterator it = FindExtension(cached_extensions_, id, expected_hash);
  if (it != cached_extensions_.end() &&
      NewerOrSame(it, version, expected_hash, nullptr)) {
    LOG(WARNING) << "Cache contains newer or the same version "
                 << it->second.version << " for extension " << id << " version "
                 << version;
    std::move(callback).Run(file_path, true);
    return;
  }

  backend_task_runner_->PostTask(
      FROM_HERE,
      base::BindOnce(&LocalExtensionCache::BackendInstallCacheEntry,
                     weak_ptr_factory_.GetWeakPtr(), cache_dir_, id,
                     expected_hash, file_path, version, std::move(callback)));
}

bool LocalExtensionCache::RemoveExtensionAt(const CacheMap::iterator& it,
                                            bool match_hash) {
  if (state_ != kReady || it == cached_extensions_.end())
    return false;
  std::string hash = match_hash ? it->second.expected_hash : std::string();
  backend_task_runner_->PostTask(
      FROM_HERE, base::BindOnce(&LocalExtensionCache::BackendRemoveCacheEntry,
                                cache_dir_, it->first, hash));
  cached_extensions_.erase(it);
  return true;
}

bool LocalExtensionCache::RemoveExtension(const std::string& id,
                                          const std::string& expected_hash) {
  if (state_ != kReady)
    return false;

  CacheMap::iterator it = FindExtension(cached_extensions_, id, expected_hash);
  if (it == cached_extensions_.end())
    return false;

  while (it != cached_extensions_.end()) {
    RemoveExtensionAt(it, !expected_hash.empty());

    // For empty |expected_hash| this will iteratively return any cached file.
    // For any specific |expected_hash| this will only be able to find the
    // matching entry once.
    it = FindExtension(cached_extensions_, id, expected_hash);
  }

  return true;
}

bool LocalExtensionCache::RemoveOnNextInit(const std::string& id) {
  if (state_ != kReady) {
    return false;
  }

  if (base::Contains(invalid_cache_ids_, id)) {
    return true;
  }

  backend_task_runner_->PostTask(
      FROM_HERE,
      base::BindOnce(&LocalExtensionCache::BackendMarkCacheInvalid,
                     weak_ptr_factory_.GetWeakPtr(), cache_dir_, id));
  invalid_cache_ids_.insert(id);
  return true;
}

bool LocalExtensionCache::GetStatistics(uint64_t* cache_size,
                                        size_t* extensions_count) {
  if (state_ != kReady)
    return false;

  *cache_size = 0;
  for (CacheMap::iterator it = cached_extensions_.begin();
       it != cached_extensions_.end(); ++it) {
    *cache_size += it->second.size;
  }
  *extensions_count = cached_extensions_.size();

  return true;
}

void LocalExtensionCache::SetCacheStatusPollingDelayForTests(
    const base::TimeDelta& delay) {
  cache_status_polling_delay_ = delay;
}

void LocalExtensionCache::CheckCacheStatus(base::OnceClosure callback) {
  if (state_ == kShutdown) {
    std::move(callback).Run();
    return;
  }

  backend_task_runner_->PostTask(
      FROM_HERE, base::BindOnce(&LocalExtensionCache::BackendCheckCacheStatus,
                                weak_ptr_factory_.GetWeakPtr(), cache_dir_,
                                std::move(callback)));
}

// static
void LocalExtensionCache::BackendCheckCacheStatus(
    base::WeakPtr<LocalExtensionCache> local_cache,
    const base::FilePath& cache_dir,
    base::OnceClosure callback) {
  base::FilePath ready_flag_file =
      cache_dir.AppendASCII(kCacheReadyFlagFileName);
  bool exists = base::PathExists(ready_flag_file);

  static bool already_warned = false;
  if (!exists && !base::SysInfo::IsRunningOnChromeOS()) {
    // This is a developer build. Automatically create the directory.
    if (base::CreateDirectory(cache_dir)) {
      base::File file(ready_flag_file, base::File::FLAG_OPEN_ALWAYS);
      if (file.IsValid()) {
        exists = true;
      } else if (!already_warned) {
        LOG(WARNING) << "Could not create cache file "
                     << ready_flag_file.value()
                     << "; extensions cannot be installed from update URLs.";
        already_warned = true;
      }
    } else if (!already_warned) {
      LOG(WARNING) << "Could not create cache directory " << cache_dir.value()
                   << "; extensions cannot be installed from update URLs.";
      already_warned = true;
    }
  }

  content::GetUIThreadTaskRunner({})->PostTask(
      FROM_HERE, base::BindOnce(&LocalExtensionCache::OnCacheStatusChecked,
                                local_cache, exists, std::move(callback)));
}

void LocalExtensionCache::OnCacheStatusChecked(bool ready,
                                               base::OnceClosure callback) {
  if (state_ == kShutdown) {
    std::move(callback).Run();
    return;
  }

  if (ready) {
    CheckCacheContents(std::move(callback));
  } else {
    content::GetUIThreadTaskRunner({})->PostDelayedTask(
        FROM_HERE,
        base::BindOnce(&LocalExtensionCache::CheckCacheStatus,
                       weak_ptr_factory_.GetWeakPtr(), std::move(callback)),
        cache_status_polling_delay_);
  }
}

void LocalExtensionCache::CheckCacheContents(base::OnceClosure callback) {
  DCHECK_EQ(state_, kWaitInitialization);
  backend_task_runner_->PostTask(
      FROM_HERE, base::BindOnce(&LocalExtensionCache::BackendCheckCacheContents,
                                weak_ptr_factory_.GetWeakPtr(), cache_dir_,
                                std::move(callback)));
}

// static
void LocalExtensionCache::BackendCheckCacheContents(
    base::WeakPtr<LocalExtensionCache> local_cache,
    const base::FilePath& cache_dir,
    base::OnceClosure callback) {
  std::unique_ptr<CacheMap> cache_content(new CacheMap);
  BackendCheckCacheContentsInternal(cache_dir, cache_content.get());
  content::GetUIThreadTaskRunner({})->PostTask(
      FROM_HERE,
      base::BindOnce(&LocalExtensionCache::OnCacheContentsChecked, local_cache,
                     std::move(cache_content), std::move(callback)));
}

// static
LocalExtensionCache::CacheMap::iterator LocalExtensionCache::InsertCacheEntry(
    CacheMap& cache,
    const std::string& id,
    const CacheItemInfo& info,
    const bool delete_files) {
  bool keep = true;
  std::string any_hash;
  // FindExtension with empty hash will always return the first one
  CacheMap::iterator it = FindExtension(cache, id, any_hash);
  if (it != cache.end()) {
    // |cache_content| already has some version for this ID. Remove older ones.
    // If we loook at the first cache entry, it may be:
    // 1. an older version (in which case we should remove all its instances)
    // 2. a newer version (in which case we should skip current file)
    // 3. the same version without hash (skip if our hash is empty,
    // 4. remove if our hash in not empty),
    // 5. the same version with hash (skip if our hash is empty,
    // 6. skip if there is already an entry with the same hash,
    // otherwise add a new entry).

    int cmp = 0;
    if (!NewerOrSame(it, info.version, info.expected_hash, &cmp)) {
      // Case #1 or #4, remove all instances from cache.
      while ((it != cache.end()) && (it->first == id)) {
        if (delete_files) {
          base::DeletePathRecursively(base::FilePath(it->second.file_path));
          VLOG(1) << "Remove older version " << it->second.version
                  << " for extension id " << id;
        }
        it = cache.erase(it);
      }
    } else if ((cmp < 0) || (cmp == 0 && info.expected_hash.empty())) {
      // Case #2, #3 or #5
      keep = false;
    } else if (cmp == 0) {
      // Same version, both hashes are not empty, try to find the same hash.
      while (keep && (it != cache.end()) && (it->first == id)) {
        if (it->second.expected_hash == info.expected_hash) {
          // Case #6
          keep = false;
        }
        ++it;
      }
    }
  }

  if (keep) {
    it = cache.insert(std::make_pair(id, info));
  } else {
    if (delete_files) {
      base::DeletePathRecursively(info.file_path);
      VLOG(1) << "Remove older version " << info.version << " for extension id "
              << id;
    }
    it = cache.end();
  }

  return it;
}

// static
void LocalExtensionCache::BackendCheckCacheContentsInternal(
    const base::FilePath& cache_dir,
    CacheMap* cache_content) {
  // Start by verifying that the cache_dir exists.
  if (!base::DirectoryExists(cache_dir)) {
    // Create it now.
    if (!base::CreateDirectory(cache_dir)) {
      LOG(ERROR) << "Failed to create cache directory at "
                 << cache_dir.value();
    }

    // Nothing else to do. Cache is empty.
    return;
  }

  std::set<std::string> invalid_cache = BackendGetInvalidCache(cache_dir);
  // Enumerate all the files in the cache |cache_dir|, including directories
  // and symlinks. Each unrecognized file will be erased.
  int types = base::FileEnumerator::FILES | base::FileEnumerator::DIRECTORIES;
  base::FileEnumerator enumerator(cache_dir, false /* recursive */, types);
  for (base::FilePath path = enumerator.Next();
       !path.empty(); path = enumerator.Next()) {
    base::FileEnumerator::FileInfo info = enumerator.GetInfo();
    std::string basename = path.BaseName().value();

    if (info.IsDirectory() || base::IsLink(info.GetName())) {
      LOG(ERROR) << "Erasing bad file in cache directory: " << basename;
      base::DeletePathRecursively(path);
      continue;
    }

    // Skip flag file that indicates that cache is ready.
    if (basename == kCacheReadyFlagFileName)
      continue;
    // Skip file with extension ids of invalidated cache.
    if (basename == kInvalidCacheIdsFileName) {
      continue;
    }

    // crx files in the cache are named
    // <extension-id>-<version>[-<expected_hash>].crx.
    std::string id;
    std::string version;
    std::string expected_hash;
    if (base::EndsWith(basename, kCRXFileExtension,
                       base::CompareCase::INSENSITIVE_ASCII)) {
      size_t n = basename.find('-');
      if (n != std::string::npos && n + 1 < basename.size() - 4) {
        id = basename.substr(0, n);
        // Size of |version| = total size - "<id>" - "-" - ".crx"
        version = basename.substr(n + 1, basename.size() - 5 - id.size());

        n = version.find('-');
        if (n != std::string::npos && n + 1 < version.size()) {
          expected_hash = version.substr(n + 1, version.size() - n - 1);
          version.resize(n);
        }
      }
    }

    // Enforce a lower-case id.
    id = base::ToLowerASCII(id);
    if (!crx_file::id_util::IdIsValid(id)) {
      LOG(ERROR) << "Bad extension id in cache: " << id;
      id.clear();
    }

    if (!base::Version(version).IsValid()) {
      LOG(ERROR) << "Bad extension version in cache: " << version;
      version.clear();
    }

    if (id.empty() || version.empty()) {
      LOG(ERROR) << "Invalid file in cache, erasing: " << basename;
      base::DeletePathRecursively(path);
      continue;
    }

    if (base::Contains(invalid_cache, id)) {
      base::DeleteFile(path);
      continue;
    }

    VLOG(1) << "Found cached version " << version
            << " for extension id " << id;

    InsertCacheEntry(
        *cache_content, id,
        CacheItemInfo(base::Version(version), expected_hash,
                      info.GetLastModifiedTime(), info.GetSize(), path),
        true);
  }

  // Delete the invalid cache file.
  base::FilePath invalid_cache_file =
      cache_dir.AppendASCII(kInvalidCacheIdsFileName);
  if (!base::DeleteFile(invalid_cache_file)) {
    LOG(WARNING) << "Failed to delete cache invalidation file "
                 << invalid_cache_file;
  }
}

void LocalExtensionCache::OnCacheContentsChecked(
    std::unique_ptr<CacheMap> cache_content,
    base::OnceClosure callback) {
  cache_content->swap(cached_extensions_);
  state_ = kReady;
  std::move(callback).Run();
}

// static
void LocalExtensionCache::BackendMarkFileUsed(const base::FilePath& file_path,
                                              const base::Time& time) {
  base::TouchFile(file_path, time, time);
}

// static
std::string LocalExtensionCache::ExtensionFileName(
    const std::string& id,
    const std::string& version,
    const std::string& expected_hash) {
  std::string filename = id + "-" + version;
  if (!expected_hash.empty())
    filename += "-" + base::ToLowerASCII(expected_hash);
  filename += kCRXFileExtension;
  return filename;
}

// static
void LocalExtensionCache::BackendInstallCacheEntry(
    base::WeakPtr<LocalExtensionCache> local_cache,
    const base::FilePath& cache_dir,
    const std::string& id,
    const std::string& expected_hash,
    const base::FilePath& file_path,
    const base::Version& version,
    PutExtensionCallback callback) {
  std::string basename =
      ExtensionFileName(id, version.GetString(), expected_hash);
  base::FilePath cached_crx_path = cache_dir.AppendASCII(basename);

  bool was_error = false;
  if (base::PathExists(cached_crx_path)) {
    LOG(ERROR) << "File already exists " << file_path.value();
    cached_crx_path = file_path;
    was_error = true;
  }

  base::File::Info info;
  if (!was_error) {
    if (!base::Move(file_path, cached_crx_path)) {
      LOG(ERROR) << "Failed to copy from " << file_path.value()
                 << " to " << cached_crx_path.value();
      cached_crx_path = file_path;
      was_error = true;
    } else {
      was_error = !base::GetFileInfo(cached_crx_path, &info);
      VLOG(1) << "Cache entry installed for extension id " << id
              << " version " << version;
    }
  }

  content::GetUIThreadTaskRunner({})->PostTask(
      FROM_HERE,
      base::BindOnce(&LocalExtensionCache::OnCacheEntryInstalled, local_cache,
                     id,
                     CacheItemInfo(version, expected_hash, info.last_modified,
                                   info.size, cached_crx_path),
                     was_error, std::move(callback)));
}

void LocalExtensionCache::OnCacheEntryInstalled(const std::string& id,
                                                const CacheItemInfo& info,
                                                bool was_error,
                                                PutExtensionCallback callback) {
  if (state_ == kShutdown || was_error) {
    // If |was_error| is true, it means that the |info.file_path| refers to the
    // original downloaded file, otherwise it refers to a file in cache, which
    // should not be deleted by CrxInstaller.
    std::move(callback).Run(info.file_path, was_error);
    return;
  }

  CacheMap::iterator it = InsertCacheEntry(cached_extensions_, id, info, false);
  if (it == cached_extensions_.end()) {
    LOG(WARNING) << "Cache contains newer or the same version for extension "
                 << id << " version " << info.version;
    std::move(callback).Run(info.file_path, true);
    return;
  }

  // Time from file system can have lower precision so use precise "now".
  it->second.last_used = base::Time::Now();

  std::move(callback).Run(info.file_path, false);
}

// static
void LocalExtensionCache::BackendRemoveCacheEntry(
    const base::FilePath& cache_dir,
    const std::string& id,
    const std::string& expected_hash) {
  std::string file_pattern = ExtensionFileName(id, "*", expected_hash);
  base::FileEnumerator enumerator(cache_dir,
                                  false /* not recursive */,
                                  base::FileEnumerator::FILES,
                                  file_pattern);
  for (base::FilePath path = enumerator.Next(); !path.empty();
       path = enumerator.Next()) {
    base::DeleteFile(path);
    VLOG(1) << "Removed cached file " << path.value();
  }
}

// static
void LocalExtensionCache::BackendMarkCacheInvalid(
    base::WeakPtr<LocalExtensionCache> local_cache,
    const base::FilePath& cache_dir,
    const std::string& extension_id) {
  base::FilePath invalid_cache_file =
      cache_dir.AppendASCII(kInvalidCacheIdsFileName);
  std::string contents = base::ToString(extension_id, kExtensionIdDelimiter);
  bool success = false;
  if (!base::PathExists(invalid_cache_file)) {
    success = base::WriteFile(invalid_cache_file, contents);
  } else {
    success = base::AppendToFile(invalid_cache_file, contents);
  }

  if (!success) {
    static bool already_warned = false;
    if (!already_warned) {
      LOG(WARNING) << "Failed writing obsolete cache extension id "
                   << extension_id << " to file " << invalid_cache_file;
      already_warned = true;
    }
    content::GetUIThreadTaskRunner({})->PostTask(
        FROM_HERE,
        base::BindOnce(&LocalExtensionCache::OnMarkCacheInvalidFailed,
                       local_cache, extension_id));
    return;
  }
}

void LocalExtensionCache::OnMarkCacheInvalidFailed(
    const std::string& extension_id) {
  // Erase extension id from invalid ids since it was not marked invalid
  // successfully.
  invalid_cache_ids_.erase(extension_id);
}

// static
std::set<std::string> LocalExtensionCache::BackendGetInvalidCache(
    const base::FilePath& cache_dir) {
  base::FilePath file = cache_dir.AppendASCII(kInvalidCacheIdsFileName);
  std::string contents;
  base::ReadFileToString(file, &contents);

  auto extension_ids =
      base::SplitString(contents, kExtensionIdDelimiter, base::TRIM_WHITESPACE,
                        base::SPLIT_WANT_NONEMPTY);
  return {std::make_move_iterator(extension_ids.begin()),
          std::make_move_iterator(extension_ids.end())};
}

// static
bool LocalExtensionCache::CompareCacheItemsAge(const CacheMap::iterator& lhs,
                                               const CacheMap::iterator& rhs) {
  return lhs->second.last_used < rhs->second.last_used;
}

void LocalExtensionCache::CleanUp() {
  DCHECK_EQ(state_, kReady);

  std::vector<CacheMap::iterator> items;
  items.reserve(cached_extensions_.size());
  uint64_t total_size = 0;
  for (CacheMap::iterator it = cached_extensions_.begin();
       it != cached_extensions_.end(); ++it) {
    items.push_back(it);
    total_size += it->second.size;
  }
  std::sort(items.begin(), items.end(), CompareCacheItemsAge);

  for (std::vector<CacheMap::iterator>::iterator it = items.begin();
       it != items.end(); ++it) {
    if ((*it)->second.last_used < min_cache_age_ ||
        (max_cache_size_ && total_size > max_cache_size_)) {
      total_size -= (*it)->second.size;
      VLOG(1) << "Clean up cached extension id " << (*it)->first;
      RemoveExtensionAt(*it, true);
    }
  }
}

LocalExtensionCache::CacheItemInfo::CacheItemInfo(
    const base::Version& version,
    const std::string& expected_hash,
    const base::Time& last_used,
    uint64_t size,
    const base::FilePath& file_path)
    : version(version),
      expected_hash(base::ToLowerASCII(expected_hash)),
      last_used(last_used),
      size(size),
      file_path(file_path) {}

LocalExtensionCache::CacheItemInfo::CacheItemInfo(const CacheItemInfo& other) =
    default;

LocalExtensionCache::CacheItemInfo::~CacheItemInfo() {
}

}  // namespace extensions