blob: 1090d90866cfc086ec1c42e5c1ad53abc9741867 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/persistent_cache/backend_params_manager.h"
#include <algorithm>
#include <array>
#include <cstdint>
#include <queue>
#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/location.h"
#include "base/metrics/histogram_functions.h"
#include "base/sequence_checker.h"
#include "base/strings/strcat.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/thread_pool.h"
#include "components/persistent_cache/sqlite/sqlite_backend_impl.h"
namespace {
#if BUILDFLAG(IS_WIN)
const uint32_t kMaxFilePathLength = MAX_PATH - 1;
#elif BUILDFLAG(IS_POSIX) || BUILDFLAG(IS_FUCHSIA)
const uint32_t kMaxFilePathLength = PATH_MAX - 1;
#endif
struct FilePathWithInfo {
base::FilePath file_path;
base::File::Info info;
};
// Comparator to be used with priority_queue to make sure that smallest times
// representing the oldest files are at the top.
class FilePathWithInfoComparator {
public:
bool operator()(const FilePathWithInfo& a, const FilePathWithInfo& b) {
return a.info.last_modified > b.info.last_modified;
}
};
const base::FilePath::CharType kDbFile[] = FILE_PATH_LITERAL(".db_file");
const base::FilePath::CharType kJournalFile[] =
FILE_PATH_LITERAL(".journal_file");
constexpr size_t kLruCacheCapacity = 100;
// Character not allowed in keys or filenames (by itself). Used to mark the
// start of a replacement token.
constexpr char kTokenMarker = '`';
// All characters allowed in filenames.
constexpr std::string_view kAllowedCharsInFilenames =
"abcdefghijklmnopqrstuvwxyz0123456789-._~"
"#[]@!$&'()+,;= ";
// Use to translate a character `c` viable for a filename into another arbitrary
// but equally viable character. To reverse the process the function is called
// with the opposite value for `forward`. If `c` is invalid empty is returned.
std::optional<char> RotateChar(char c, bool forward) {
static_assert(kAllowedCharsInFilenames.length() < 128,
"Allowed chars are a subset of ASCII and overflow while "
"indexing should never be a worry");
size_t char_index = kAllowedCharsInFilenames.find(c);
// Characters illegal in filenames are not handled in this function.
if (char_index == std::string::npos) {
return std::nullopt;
}
// Arbitrary offset to rotate index in the list of allowed characters.
constexpr int64_t kRotationOffset = 37;
// Use a rotating index to find a character to replace `c`. Using XOR is not
// viable because it doesn't always give a character that is viable in a
// filename.
if (forward) {
return kAllowedCharsInFilenames[(char_index + kRotationOffset) %
kAllowedCharsInFilenames.length()];
}
return kAllowedCharsInFilenames[(char_index +
kAllowedCharsInFilenames.length() -
kRotationOffset) %
kAllowedCharsInFilenames.length()];
}
// Mapping of characters illegal in filenames to a unique token to represent
// them in filenames. This prevents collisions by avoiding two characters get
// mapped to the same value. Ex:
// "*/" --> "`9`2"
// "><" --> "`5`4"
//
// Mapping both strings to "`1`1" for example would result in a valid filename
// but in backing files being shared for two keys which is not correct.
static_assert(kAllowedCharsInFilenames.find(kTokenMarker) == std::string::npos,
"Space is not allowed in filenames by itself.");
using ConstStringPair = std::pair<char, const char*>;
std::array<ConstStringPair, 10> kCharacterToTokenMap{
ConstStringPair{'\\', "`1"}, ConstStringPair{'/', "`2"},
ConstStringPair{'|', "`3"}, ConstStringPair{'<', "`4"},
ConstStringPair{'>', "`5"}, ConstStringPair{':', "`6"},
ConstStringPair{'\"', "`7"}, ConstStringPair{'?', "`8"},
ConstStringPair{'*', "`9"}, ConstStringPair{'\n', "`0"}};
// Use to get a token to insert in a filename if `c` is a character
// illegal in filenames and an empty string if it's not.
std::string_view FilenameIllegalCharToReplacementToken(char c) {
for (const auto& pair : kCharacterToTokenMap) {
if (c == pair.first) {
return pair.second;
}
}
return "";
}
// Use to get a character associated with `token` if it exists and empty
// if it doesn't.
std::optional<char> ReplacementTokenToFilenameIllegalChar(
std::string_view token) {
for (const auto& pair : kCharacterToTokenMap) {
if (token == pair.second) {
return pair.first;
}
}
return {};
}
} // namespace
namespace persistent_cache {
BackendParamsManager::BackendParamsManager(base::FilePath top_directory)
: backend_params_map_(kLruCacheCapacity),
top_directory_(std::move(top_directory)) {
if (!base::PathExists(top_directory_)) {
base::CreateDirectory(top_directory_);
}
}
BackendParamsManager::~BackendParamsManager() = default;
void BackendParamsManager::GetParamsSyncOrCreateAsync(
BackendType backend_type,
const std::string& key,
AccessRights access_rights,
CompletedCallback callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
auto it = backend_params_map_.Get(
BackendParamsKey{.backend_type = backend_type, .key = key});
if (it != backend_params_map_.end()) {
std::move(callback).Run(it->second);
return;
}
std::string filename = FileNameFromKey(key);
if (filename.empty()) {
std::move(callback).Run(BackendParams());
return;
}
base::ThreadPool::PostTaskAndReplyWithResult(
FROM_HERE,
{base::MayBlock(), base::TaskShutdownBehavior::CONTINUE_ON_SHUTDOWN},
base::BindOnce(&BackendParamsManager::CreateParamsSync, top_directory_,
backend_type, filename, access_rights),
base::BindOnce(&BackendParamsManager::SaveParams,
weak_factory_.GetWeakPtr(), key, std::move(callback)));
}
BackendParams BackendParamsManager::GetOrCreateParamsSync(
BackendType backend_type,
const std::string& key,
AccessRights access_rights) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
auto it = backend_params_map_.Get(
BackendParamsKey{.backend_type = backend_type, .key = key});
if (it != backend_params_map_.end()) {
return it->second.Copy();
}
std::string filename = FileNameFromKey(key);
if (filename.empty()) {
return BackendParams();
}
BackendParams new_params =
CreateParamsSync(top_directory_, backend_type, filename, access_rights);
SaveParams(key, CompletedCallback(), new_params.Copy());
return new_params;
}
void BackendParamsManager::DeleteAllFiles() {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
// Clear params cache so they don't hold on to files or prevent their
// deletion. BackendParam instances that were vended by this class and
// retained somewhere else can still create problems and need to be handled
// appropriately.
backend_params_map_.Clear();
base::DeletePathRecursively(top_directory_);
// Recreate the directory since the objective was to delete files only.
base::CreateDirectory(top_directory_);
}
FootprintReductionResult BackendParamsManager::BringDownTotalFootprintOfFiles(
int64_t target_footprint) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
// Clear params cache so they don't hold on to files or prevent their
// deletion. BackendParam instances that were vended by this class and
// retained somewhere else can still create problems and need to be handled
// appropriately.
backend_params_map_.Clear();
int64_t total_footprint = 0;
std::priority_queue<FilePathWithInfo, std::vector<FilePathWithInfo>,
FilePathWithInfoComparator>
file_paths_with_info;
base::FileEnumerator file_enumerator(top_directory_, /*recursive=*/false,
base::FileEnumerator::FILES);
file_enumerator.ForEach([&total_footprint, &file_paths_with_info](
const base::FilePath& file_path) {
base::File::Info info;
base::GetFileInfo(file_path, &info);
// Only target database files for deletion.
if (file_path.MatchesFinalExtension(kDbFile)) {
file_paths_with_info.emplace(file_path, info);
}
// All files count towards measured footprint.
total_footprint += info.size;
});
// Nothing to do.
if (total_footprint <= target_footprint) {
return FootprintReductionResult{.current_footprint = total_footprint,
.number_of_bytes_deleted = 0};
}
int64_t size_of_necessary_deletes = total_footprint - target_footprint;
int64_t deleted_size = 0;
while (!file_paths_with_info.empty()) {
if (size_of_necessary_deletes <= deleted_size) {
break;
}
const FilePathWithInfo& file_path_with_info = file_paths_with_info.top();
bool db_file_delete_success =
base::DeleteFile(file_path_with_info.file_path);
base::UmaHistogramBoolean(
"PersistentCache.ParamsManager.DbFile.DeleteSucess",
db_file_delete_success);
if (db_file_delete_success) {
deleted_size += file_path_with_info.info.size;
base::FilePath journal_file_path =
file_path_with_info.file_path.ReplaceExtension(kJournalFile);
base::File::Info journal_file_info;
base::GetFileInfo(journal_file_path, &journal_file_info);
// TODO (https://crbug.com/377475540): Cleanup when deletion of journal
// failed.
bool journal_file_delete_success = base::DeleteFile(journal_file_path);
base::UmaHistogramBoolean(
"PersistentCache.ParamsManager.JournalFile.DeleteSucess",
journal_file_delete_success);
if (journal_file_delete_success) {
deleted_size += journal_file_info.size;
}
};
file_paths_with_info.pop();
}
return FootprintReductionResult{
.current_footprint = total_footprint - deleted_size,
.number_of_bytes_deleted = deleted_size};
}
// static
std::string BackendParamsManager::FileNameFromKey(const std::string& key) {
std::string filename;
filename.reserve(key.size());
for (char c : key) {
std::string_view token = FilenameIllegalCharToReplacementToken(c);
if (!token.empty()) {
filename += token;
} else {
std::optional<char> rotated_char = RotateChar(c, true);
if (!rotated_char.has_value()) {
// There's no way to rotate an illegal character so return an empty
// string.
return "";
}
filename += rotated_char.value();
}
}
return filename;
}
// static
std::string BackendParamsManager::KeyFromFileName(const std::string& filename) {
std::string key;
key.reserve(filename.size());
for (auto it = filename.begin(); it != filename.end(); ++it) {
if (*it == kTokenMarker) {
// Token markers cannot be by themselves in filenames. Return an empty
// string instead of CHECKing here because it's not advisable to have a
// crash because something renamed a file.
if (it + 1 == filename.end()) {
return "";
}
std::optional<char> c =
ReplacementTokenToFilenameIllegalChar(std::string_view(it, it + 2));
if (c.has_value()) {
key += c.value();
// Skip the character already parsed.
++it;
continue;
}
// If execution gets here it's that a token marker was followed by a
// character that didn't resolve to anything. This means the file name is
// invalid.
return "";
} else {
std::optional<char> rotated_char = RotateChar(*it, false);
if (!rotated_char.has_value()) {
// There's no way to rotate an illegal character so return an empty
// string.
return "";
}
key += rotated_char.value();
}
}
return key;
}
// static
BackendParams BackendParamsManager::CreateParamsSync(
base::FilePath directory,
BackendType backend_type,
const std::string& filename,
AccessRights access_rights) {
BackendParams params;
params.type = backend_type;
const bool writes_supported = (access_rights == AccessRights::kReadWrite);
uint32_t flags = base::File::FLAG_OPEN_ALWAYS | base::File::FLAG_READ;
if (writes_supported) {
flags |= base::File::FLAG_WRITE;
}
#if BUILDFLAG(IS_WIN)
// PersistentCache backing files are not executables.
flags |= base::File::FLAG_WIN_NO_EXECUTE;
// String conversion to wstring necessary on Windows.
std::wstring filename_part = base::UTF8ToWide(filename);
base::FilePath db_file_name =
base::FilePath(base::StrCat({filename_part, kDbFile}));
base::FilePath journal_file_name =
base::FilePath(base::StrCat({filename_part, kJournalFile}));
#else
base::FilePath db_file_name =
base::FilePath(base::StrCat({filename, kDbFile}));
base::FilePath journal_file_name =
base::FilePath(base::StrCat({filename, kJournalFile}));
#endif
base::FilePath db_file_full_path = directory.Append(db_file_name);
params.db_file = base::File(db_file_full_path, flags);
params.db_file_is_writable = writes_supported;
base::FilePath journal_file_full_path = directory.Append(journal_file_name);
params.journal_file = base::File(journal_file_full_path, flags);
params.journal_file_is_writable = writes_supported;
if (!params.db_file.IsValid() || !params.journal_file.IsValid()) {
size_t smallest_path_length =
std::min(db_file_full_path.value().length(),
journal_file_full_path.value().length());
if (smallest_path_length > kMaxFilePathLength) {
base::UmaHistogramCounts100(
"PersistentCache.ParamsManager.FilenameCharactersOverLimit",
smallest_path_length - kMaxFilePathLength);
}
}
return params;
}
void BackendParamsManager::SaveParams(const std::string& key,
CompletedCallback callback,
BackendParams backend_params) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (callback) {
std::move(callback).Run(backend_params);
}
// Avoid saving invalid files.
if (backend_params.db_file.IsValid() &&
backend_params.journal_file.IsValid()) {
backend_params_map_.Put(
BackendParamsKey{.backend_type = backend_params.type, .key = key},
std::move(backend_params));
}
}
// static
std::string BackendParamsManager::GetAllAllowedCharactersInKeysForTesting() {
// Start with all characters allowed in both keys and filenames.
std::string allowed_characters(kAllowedCharsInFilenames);
// Add characters only allowed in keys.
for (const auto& pair : kCharacterToTokenMap) {
allowed_characters += pair.first;
}
return allowed_characters;
}
} // namespace persistent_cache