0

Implement No-Vary-Search in-memory store

Implement net::NoVarySearchCache, an in-memory mapping from URLs that
will be looked up in the cache to previously-fetched URLs that are
equivalent under the rules of No-Vary-Search (see
https://httpwg.org/http-extensions/draft-ietf-httpbis-no-vary-search.html).

This will be used to add No-Vary-Search support to
net::HttpCache::Transaction in a future CL.

This version does not yet support persistence of the cache to disk. That
will be implemented in a future version.

NoVarySearchCache will be owned by HttpCache, so there will be one
instance per URLRequestContext. The object will be created when a
Profile is created, and exist until it is destroyed (usually
browser shutdown).

See the design doc at
https://docs.google.com/document/d/1RS3q6qZ7-k9CvZsDYseGOXzcdQ9fGZ6YYnaW7fTPu7A/edit.

BUG=382394774
BYPASS_LARGE_CHANGE_WARNING=It is what it is
NO_IFTTT=I did change enums.xml but it complains anyway.

Change-Id: Ief53f1b4fd1f94366b3d719635d8b4fc553da5fd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6088312
Commit-Queue: Adam Rice <ricea@chromium.org>
Reviewed-by: Takashi Toyoshima <toyoshim@chromium.org>
Reviewed-by: Hiroki Nakagawa <nhiroki@chromium.org>
Auto-Submit: Adam Rice <ricea@chromium.org>
Reviewed-by: Kenichi Ishibashi <bashi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1407159}
This commit is contained in:
Adam Rice
2025-01-16 00:22:50 -08:00
committed by Chromium LUCI CQ
parent 74674436b6
commit c7f62ad3eb
9 changed files with 1331 additions and 2 deletions

View File

@ -675,6 +675,8 @@ component("net") {
"http/http_vary_data.cc",
"http/http_vary_data.h",
"http/http_version.h",
"http/no_vary_search_cache.cc",
"http/no_vary_search_cache.h",
"http/partial_data.cc",
"http/partial_data.h",
"http/proxy_client_socket.cc",
@ -2794,6 +2796,7 @@ target(_test_target_type, "net_unittests") {
"http/http_vary_data_unittest.cc",
"http/mock_allow_http_auth_preferences.cc",
"http/mock_allow_http_auth_preferences.h",
"http/no_vary_search_cache_unittest.cc",
"http/test_upload_data_stream_not_allow_http1.cc",
"http/test_upload_data_stream_not_allow_http1.h",
"http/transport_security_persister_unittest.cc",

View File

@ -110,7 +110,7 @@ HttpNoVarySearchData::ParseFromHeaders(
std::optional<std::string> normalized_header =
response_headers.GetNormalizedHeader("No-Vary-Search");
if (!normalized_header) {
// This means there is no No-Vary-Search header. Return nullopt.
// This means there is no No-Vary-Search header.
return base::unexpected(ParseErrorEnum::kOk);
}
@ -124,6 +124,11 @@ HttpNoVarySearchData::ParseFromHeaders(
return ParseNoVarySearchDictionary(dict.value());
}
bool HttpNoVarySearchData::operator==(const HttpNoVarySearchData& rhs) const =
default;
std::strong_ordering HttpNoVarySearchData::operator<=>(
const HttpNoVarySearchData& rhs) const = default;
const base::flat_set<std::string>& HttpNoVarySearchData::no_vary_params()
const {
return no_vary_params_;

View File

@ -24,7 +24,7 @@ class HttpResponseHeaders;
class NET_EXPORT_PRIVATE HttpNoVarySearchData {
public:
enum class ParseErrorEnum {
kOk, // Parsing is correct. Also returned if there is no header.
kOk, // There is no No-Vary-Search header.
kDefaultValue, // Parsing is correct but led to default value - the header
// could be removed.
kNotDictionary, // Header value is not a dictionary.
@ -56,6 +56,11 @@ class NET_EXPORT_PRIVATE HttpNoVarySearchData {
HttpNoVarySearchData::ParseErrorEnum>
ParseFromHeaders(const HttpResponseHeaders& response_headers);
bool operator==(const HttpNoVarySearchData& rhs) const;
// HttpNoVarySearchData objects can be used as a key in a map.
std::strong_ordering operator<=>(const HttpNoVarySearchData& rhs) const;
bool AreEquivalent(const GURL& a, const GURL& b) const;
const base::flat_set<std::string>& no_vary_params() const;

View File

@ -9,11 +9,15 @@
#include "net/http/http_no_vary_search_data.h"
#include <algorithm>
#include <array>
#include <string>
#include <string_view>
#include <vector>
#include "base/containers/flat_map.h"
#include "base/containers/flat_set.h"
#include "base/containers/to_vector.h"
#include "base/memory/scoped_refptr.h"
#include "base/strings/string_util.h"
#include "base/test/gmock_expected_support.h"
@ -1178,6 +1182,42 @@ TEST(HttpNoVarySearchResponseHeadersParseHistogramTest, UnrecognizedKeys) {
"Net.HttpNoVarySearch.HasUnrecognizedKeys", true, 1);
}
TEST(HttpNoVarySearchDataTest, ComparisonOperators) {
constexpr auto kValues = std::to_array<std::string_view>(
{"params", "key-order", "params, key-order", R"(params=("a"))",
R"(params=("b"))", R"(params, except=("a"))", R"(params, except=("b"))",
R"(params, except=("a"), key-order)"});
auto data_vector = base::ToVector(kValues, [](std::string_view value) {
auto headers = HttpResponseHeaders::Builder({1, 1}, "200 OK")
.AddHeader("No-Vary-Search", value)
.Build();
auto result = HttpNoVarySearchData::ParseFromHeaders(*headers);
CHECK(result.has_value());
return result.value();
});
// We don't actually care what the order is, just that it is consistent, so
// sort the vector.
std::ranges::sort(data_vector);
// Compare everything to itself.
for (const auto& data : data_vector) {
EXPECT_EQ(data, data);
EXPECT_EQ(data <=> data, std::strong_ordering::equal);
}
// Compare everything to everything else.
for (size_t i = 0; i < data_vector.size() - 1; ++i) {
for (size_t j = i + 1; j < data_vector.size(); ++j) {
// Commutativity of !=.
EXPECT_NE(data_vector[i], data_vector[j]);
EXPECT_NE(data_vector[j], data_vector[i]);
// Transitivity of <.
EXPECT_LT(data_vector[i], data_vector[j]);
EXPECT_GT(data_vector[j], data_vector[i]);
}
}
}
} // namespace
} // namespace net

View File

@ -0,0 +1,512 @@
// 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 "net/http/no_vary_search_cache.h"
#include <compare>
#include <iostream>
#include <tuple>
#include <type_traits>
#include <utility>
#include "base/check.h"
#include "base/check_op.h"
#include "base/memory/weak_ptr.h"
#include "base/metrics/histogram_macros.h"
#include "base/notreached.h"
#include "base/time/time.h"
#include "net/http/http_no_vary_search_data.h"
namespace net {
namespace {
// We need to use a separate enum for the
// HttpCache.NoVarySearch.HeaderParseResult than
// HttpNoVarySearchData::ParseErrorEnum, as that enum does not have a value for
// a successful parse.
//
// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused.
//
// LINT.IfChange(NoVarySearchHeaderParseResult)
enum class NoVarySearchHeaderParseResult {
kSuccess = 0,
kNoHeader = 1,
kDefaultValue = 2,
kNotDictionary = 3,
kNonBooleanKeyOrder = 4,
kParamsNotStringList = 5,
kExceptNotStringList = 6,
kExceptWithoutTrueParams = 7,
kMaxValue = kExceptWithoutTrueParams,
};
// LINT.ThenChange(//tools/metrics/histograms/metadata/net/enums.xml:NoVarySearchHeaderParseResult)
NoVarySearchHeaderParseResult MapParseErrorEnum(
HttpNoVarySearchData::ParseErrorEnum error) {
using enum HttpNoVarySearchData::ParseErrorEnum;
switch (error) {
case kOk:
return NoVarySearchHeaderParseResult::kNoHeader;
case kDefaultValue:
return NoVarySearchHeaderParseResult::kDefaultValue;
case kNotDictionary:
return NoVarySearchHeaderParseResult::kNotDictionary;
case kUnknownDictionaryKey:
NOTREACHED(); // No longer used.
case kNonBooleanKeyOrder:
return NoVarySearchHeaderParseResult::kNonBooleanKeyOrder;
case kParamsNotStringList:
return NoVarySearchHeaderParseResult::kParamsNotStringList;
case kExceptNotStringList:
return NoVarySearchHeaderParseResult::kExceptNotStringList;
case kExceptWithoutTrueParams:
return NoVarySearchHeaderParseResult::kExceptWithoutTrueParams;
}
NOTREACHED();
}
void EmitNoVarySearchHeaderParseResultHistogram(
const base::expected<HttpNoVarySearchData,
HttpNoVarySearchData::ParseErrorEnum>& result) {
auto value = NoVarySearchHeaderParseResult::kSuccess;
if (!result.has_value()) {
value = MapParseErrorEnum(result.error());
}
UMA_HISTOGRAM_ENUMERATION("HttpCache.NoVarySearch.HeaderParseResult", value);
}
// Stripping the URL of its query and fragment (ref) needs to be done for every
// request, so we want to avoid allocating memory for a GURL in the case of a
// cache miss. The return value points at memory owned by `url`, so should not
// outlive it.
std::string_view ExtractBaseURLView(const GURL& url) {
CHECK(url.is_valid());
CHECK(url.has_path());
const std::string& spec = url.possibly_invalid_spec();
const url::Parsed& parsed = url.parsed_for_possibly_invalid_spec();
const url::Component& path = parsed.path;
CHECK(path.is_valid());
const size_t path_end_offset = base::checked_cast<size_t>(path.end());
auto base_url = std::string_view(spec).substr(0, path_end_offset);
if constexpr (DCHECK_IS_ON()) {
// This correctness check is very expensive, so is only done when DCHECKs
// are enabled.
GURL generated_base_url(base_url);
DCHECK(generated_base_url.is_valid());
GURL::Replacements replacements;
replacements.ClearQuery();
replacements.ClearRef();
GURL replaced_url = url.ReplaceComponents(replacements);
DCHECK_EQ(generated_base_url, replaced_url);
}
return base_url;
}
bool URLIsAcceptable(const GURL& url) {
// HTTP(S) URLs always have a path starting with "/" after canonicalization.
return url.is_valid() && url.has_path() && !url.has_username() &&
!url.has_password();
}
} // namespace
// base::LinkedList only supports having an object in a single linked list at a
// time. However, we need QueryString objects to be in two separate lists:
// * the least-recently-used (LRU) list, which has the most recently added or
// used entry at its head and the next entry to be evicted at its tail. This
// list contains every QueryString in the cache.
// * the list of cached QueryString objects for a particular base URL and
// No-Vary-Search parameter. These lists have the most recently inserted
// entry for this {base URL, NVS} pair at their heads.
//
// In order to work-around the limitation of base::LinkedList, QueryString has
// two base classes. LruNode represents its role as a member of the LRU list,
// and QueryStringListNode represents its role as a member of the
// QueryStringList list. By calling base::LinkNode methods via the appropriate
// base class it can control which list it is manipulating.
class NoVarySearchCache::LruNode : public base::LinkNode<LruNode> {
public:
// Not copyable or movable.
LruNode(const LruNode&) = delete;
LruNode& operator=(const LruNode&) = delete;
// Every instance of LruNode is actually a QueryString.
QueryString* ToQueryString();
private:
friend class QueryString;
LruNode() = default;
~LruNode() = default;
};
class NoVarySearchCache::QueryStringListNode
: public base::LinkNode<QueryStringListNode> {
public:
// Not copyable or movable.
QueryStringListNode(const QueryStringListNode&) = delete;
QueryStringListNode& operator=(const QueryStringListNode&) = delete;
// Every instance of QueryStringListNode is actually a QueryString.
QueryString* ToQueryString();
private:
friend class QueryString;
QueryStringListNode() = default;
~QueryStringListNode() = default;
};
// QueryString is the entry type for the cache. Its main purpose is to hold the
// query string, ie. everything between the "?" and the "#" in the original URL.
// Together with the `base_url` that forms part of the Key, this can be used to
// reconstruct the original URL that was used to store the original request in
// the disk cache.
class NoVarySearchCache::QueryString final
: public NoVarySearchCache::LruNode,
public NoVarySearchCache::QueryStringListNode {
public:
// Creates a QueryString and adds it to `list` and `lru_list`.
static void CreateAndInsert(std::optional<std::string_view> query,
QueryStringList& query_string_list,
base::LinkedList<LruNode>& lru_list) {
DCHECK(!query || query->find('#') == std::string_view::npos)
<< "Query contained a '#' character, meaning that the URL reassembly "
"will not work correctly because the '#' will be re-interpreted as "
"the start of a fragment. This should not happen. Query was '"
<< query.value() << "'";
// This use of bare new is needed because base::LinkedList does not have
// ownership semantics.
auto* query_string = new QueryString(query, query_string_list);
query_string->LruNode::InsertBefore(lru_list.head());
query_string->QueryStringListNode::InsertBefore(
query_string_list.list.head());
}
// Not copyable or movable.
QueryString(const QueryString&) = delete;
QueryString& operator=(const QueryString&) = delete;
// Removes this object from both lists and deletes it.
void RemoveAndDelete() {
LruNode::RemoveFromList();
QueryStringListNode::RemoveFromList();
delete this;
}
// Moves this object to the head of `list`.
template <typename List>
void MoveToHead(List& linked_list) {
auto* head = linked_list.head();
if (head != this) {
MoveBeforeNode(linked_list.head()->value());
}
}
const std::optional<std::string>& query() const { return query_; }
QueryStringList& query_string_list_ref() {
return query_string_list_ref_.get();
}
base::Time insertion_time() const { return insertion_time_; }
void UpdateInsertionTime() { insertion_time_ = base::Time::Now(); }
// Return the original GURL that this entry was constructed from (not
// including any fragment). It's important to use this method to correctly
// reconstruct URLs that have an empty query (end in '?').
GURL ReconstructOriginalURL(std::string_view base_url) {
if (query_.has_value()) {
return GURL(base::StrCat({base_url, "?", query_.value()}));
} else {
return GURL(base_url);
}
}
EraseHandle CreateEraseHandle() {
return EraseHandle(weak_factory_.GetWeakPtr());
}
private:
QueryString(std::optional<std::string_view> query,
QueryStringList& query_string_list)
: query_(query),
query_string_list_ref_(query_string_list),
insertion_time_(base::Time::Now()) {}
// Must only be called from RemoveAndDelete().
~QueryString() = default;
// Moves this object in front of `node`.
template <typename NodeType>
void MoveBeforeNode(NodeType* node) {
static_assert(std::same_as<NodeType, LruNode> ||
std::same_as<NodeType, QueryStringListNode>,
"This assert is just documentation");
CHECK_NE(node, this);
NodeType::RemoveFromList();
NodeType::InsertBefore(node);
}
// No-Vary-Search treats "http://www.example.com/" and
// "http://www.example.com/?" as the same URL, but the disk cache key treats
// them as different URLs, so we need to be able to distinguish them to
// correctly reconstruct the original URL. `query_ == std::nullopt` means that
// there was no `?` in the original URL, and `query_ == ""` means there was.
const std::optional<std::string> query_;
// `query_string_list_ref_` allows the keys for this entry to be located in
// the cache so that it can be erased efficiently.
const raw_ref<QueryStringList> query_string_list_ref_;
// `insertion_time_` breaks ties when there are multiple possible matches. The
// most recent entry will be used as it is most likely to still exist in the
// disk cache.
base::Time insertion_time_;
// EraseHandle uses weak pointers to QueryString objects to enable an entry to
// be deleted from the cache if it is found not to be readable from the disk
// cache.
base::WeakPtrFactory<QueryString> weak_factory_{this};
};
// The two implementations of ToQueryString() are defined out-of-line so that
// the compiler has seen the definition of QueryString and so knows the correct
// offsets to apply to the `this` pointer.
NoVarySearchCache::QueryString* NoVarySearchCache::LruNode::ToQueryString() {
return static_cast<QueryString*>(this);
}
NoVarySearchCache::QueryString*
NoVarySearchCache::QueryStringListNode::ToQueryString() {
return static_cast<QueryString*>(this);
}
NoVarySearchCache::EraseHandle::EraseHandle(
base::WeakPtr<QueryString> query_string)
: query_string_(std::move(query_string)) {}
NoVarySearchCache::EraseHandle::~EraseHandle() = default;
NoVarySearchCache::EraseHandle::EraseHandle(EraseHandle&& rhs) = default;
NoVarySearchCache::EraseHandle& NoVarySearchCache::EraseHandle::operator=(
EraseHandle&& rhs) = default;
bool NoVarySearchCache::EraseHandle::EqualsForTesting(
const EraseHandle& rhs) const {
return query_string_.get() == rhs.query_string_.get();
}
bool NoVarySearchCache::EraseHandle::IsGoneForTesting() const {
return !query_string_;
}
NoVarySearchCache::NoVarySearchCache(size_t max_size) : max_size_(max_size) {
CHECK_GT(max_size_, 0u);
}
NoVarySearchCache::~NoVarySearchCache() {
map_.clear();
// Clearing the map should have freed all the QueryString objects.
CHECK(lru_.empty());
}
std::optional<NoVarySearchCache::LookupResult> NoVarySearchCache::Lookup(
const NetworkIsolationKey& nik,
const GURL& url) {
if (nik.IsTransient() || !URLIsAcceptable(url)) {
return std::nullopt;
}
std::string_view base_url = ExtractBaseURLView(url);
const auto it = map_.find(KeyReference(nik, base_url));
if (it == map_.end()) {
return std::nullopt;
}
// We have a match, so we need to create a real URL now.
QueryString* best_match = nullptr;
GURL original_url;
for (auto& [nvs_data, query_strings] : it->second) {
auto result = FindQueryStringInList(query_strings, base_url, url, nvs_data);
if (result && (!best_match || best_match->insertion_time() <
result->match->insertion_time())) {
best_match = result->match;
original_url = result->original_url;
}
}
if (!best_match) {
return std::nullopt;
}
// Move to head of `lru_` list.
best_match->MoveToHead(lru_);
return LookupResult(original_url, best_match->CreateEraseHandle());
}
void NoVarySearchCache::MaybeInsert(const NetworkIsolationKey& nik,
const GURL& url,
const HttpResponseHeaders& headers) {
if (nik.IsTransient() || !URLIsAcceptable(url)) {
return;
}
auto maybe_nvs_data = HttpNoVarySearchData::ParseFromHeaders(headers);
EmitNoVarySearchHeaderParseResultHistogram(maybe_nvs_data);
if (!maybe_nvs_data.has_value()) {
return;
}
const std::string_view base_url = ExtractBaseURLView(url);
std::optional<std::string_view> query;
if (url.has_query()) {
query = url.query_piece();
}
// Using lower_bound() followed by emplace_hint() allows us to avoid
// constructing a Key object if there is already a matching key in the map,
// and do only a single logN lookup.
const KeyReference key(nik, base_url);
auto it = map_.lower_bound(key);
if (it == map_.end() || it->first != key) {
it = map_.emplace_hint(it, Key(nik, GURL(base_url)), DataMapType());
}
DataMapType& data_map = it->second;
auto [data_it, inserted] =
data_map.emplace(std::move(*maybe_nvs_data), it->first);
const HttpNoVarySearchData& nvs_data = data_it->first;
QueryStringList& query_strings = data_it->second;
if (inserted) {
query_strings.nvs_data_ref = &nvs_data;
} else {
// There was already an entry for this `nvs_data`. We need to check if it
// has a match for the URL we're trying to insert. If it does, we should
// update or replace the existing QueryString.
if (auto result =
FindQueryStringInList(query_strings, base_url, url, nvs_data)) {
QueryString* match = result->match;
if (match->query() == query) {
// In the exact match case we can use the existing QueryString object.
match->UpdateInsertionTime();
match->MoveToHead(query_strings.list);
match->MoveToHead(lru_);
return;
}
// No-Vary-Search matches are transitive. Any future requests that might
// be a match for `match` are also a match for `url`. Since `url` is newer
// we will prefer it, and so `match` will never be used again and we can
// safely remove it from the cache.
--size_;
match->RemoveAndDelete();
}
}
EvictIfFull();
CHECK_LT(size_, max_size_);
++size_;
QueryString::CreateAndInsert(query, query_strings, lru_);
}
void NoVarySearchCache::Erase(EraseHandle handle) {
if (QueryString* query_string = handle.query_string_.get()) {
EraseQuery(query_string);
}
}
// This is out-of-line to discourage inlining so the bots can detect if it is
// accidentally linked into the binary.
size_t NoVarySearchCache::GetSizeForTesting() const {
return size_;
}
bool NoVarySearchCache::IsTopLevelMapEmptyForTesting() const {
return map_.empty();
}
bool NoVarySearchCache::Key::operator==(const Key& rhs) const = default;
bool NoVarySearchCache::KeyComparator::operator()(const Key& lhs,
const Key& rhs) const {
return std::tie(lhs.nik, lhs.base_url) < std::tie(rhs.nik, rhs.base_url);
}
bool NoVarySearchCache::KeyComparator::operator()(
const Key& lhs,
const KeyReference& rhs) const {
const auto lhs_ref =
KeyReference(lhs.nik, lhs.base_url.possibly_invalid_spec());
return lhs_ref < rhs;
}
bool NoVarySearchCache::KeyComparator::operator()(const KeyReference& lhs,
const Key& rhs) const {
const auto rhs_ref =
KeyReference(rhs.nik, rhs.base_url.possibly_invalid_spec());
return lhs < rhs_ref;
}
NoVarySearchCache::QueryStringList::QueryStringList(const Key& key)
: key_ref(key) {}
NoVarySearchCache::QueryStringList::~QueryStringList() {
while (!list.empty()) {
list.head()->value()->ToQueryString()->RemoveAndDelete();
}
}
void NoVarySearchCache::EvictIfFull() {
CHECK_LE(size_, max_size_);
if (size_ == max_size_) {
EraseQuery(lru_.tail()->value()->ToQueryString());
}
}
void NoVarySearchCache::EraseQuery(QueryString* query_string) {
CHECK_GT(size_, 0u);
--size_;
const QueryStringList& query_strings = query_string->query_string_list_ref();
query_string->RemoveAndDelete();
if (query_strings.list.empty()) {
const HttpNoVarySearchData* nvs_data_ref = query_strings.nvs_data_ref.get();
const Key& key_ref = query_strings.key_ref.get();
const auto map_it = map_.find(key_ref);
CHECK(map_it != map_.end());
size_t removed_count = map_it->second.erase(*nvs_data_ref);
CHECK_EQ(removed_count, 1u);
if (map_it->second.empty()) {
map_.erase(map_it);
}
}
}
// static
std::optional<NoVarySearchCache::FindQueryStringResult>
NoVarySearchCache::FindQueryStringInList(QueryStringList& query_strings,
std::string_view base_url,
const GURL& url,
const HttpNoVarySearchData& nvs_data) {
for (auto* node = query_strings.list.head(); node != query_strings.list.end();
node = node->next()) {
QueryString* query_string = node->value()->ToQueryString();
// TODO(crbug.com/382394774): Stop allocating GURLs in a tight loop.
GURL node_url = query_string->ReconstructOriginalURL(base_url);
CHECK(node_url.is_valid());
if (nvs_data.AreEquivalent(url, node_url)) {
return FindQueryStringResult(query_string, std::move(node_url));
}
}
return std::nullopt;
}
} // namespace net

View File

@ -0,0 +1,200 @@
// 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.
#ifndef NET_HTTP_NO_VARY_SEARCH_CACHE_H_
#define NET_HTTP_NO_VARY_SEARCH_CACHE_H_
#include <stddef.h>
#include <compare>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <tuple>
#include <utility>
#include "base/containers/linked_list.h"
#include "base/memory/raw_ptr.h"
#include "base/memory/raw_ref.h"
#include "base/memory/stack_allocated.h"
#include "base/memory/weak_ptr.h"
#include "net/base/net_export.h"
#include "net/base/network_isolation_key.h"
#include "net/http/http_no_vary_search_data.h"
#include "url/gurl.h"
namespace net {
class HttpResponseHeaders;
// An in-memory cache that permits looking up a {NIK, URL} pair and seeing if it
// matches a previous response according to the rules of the No-Vary-Search
// header (see
// https://httpwg.org/http-extensions/draft-ietf-httpbis-no-vary-search.html).
// See also the design doc at
// https://docs.google.com/document/d/1RS3q6qZ7-k9CvZsDYseGOXzcdQ9fGZ6YYnaW7fTPu7A/edit
//
// Owned by net::HttpCache.
//
// Ignoring eviction, the data structure is approximately equivalent to
// std::map<std::tuple<NetworkIsolationKey, GURL, HttpNoVarySearchData>,
// QueryString>.
class NET_EXPORT_PRIVATE NoVarySearchCache {
private:
// Declared here so that it can be mentioned in the definition of EraseHandle.
class QueryString;
public:
// Opaque object that permits erasure of an item from the cache.
// See comments on the Lookup() and Erase() methods for usage.
class NET_EXPORT_PRIVATE EraseHandle {
public:
~EraseHandle();
// Not copyable.
EraseHandle(const EraseHandle&) = delete;
EraseHandle& operator=(const EraseHandle&) = delete;
// Movable.
EraseHandle(EraseHandle&& rhs);
EraseHandle& operator=(EraseHandle&& rhs);
// For unit tests it is useful to be able to inspect this.
bool EqualsForTesting(const EraseHandle& rhs) const;
bool IsGoneForTesting() const;
private:
friend class NoVarySearchCache;
friend class QueryString;
explicit EraseHandle(base::WeakPtr<QueryString> query_string);
base::WeakPtr<QueryString> query_string_;
};
struct LookupResult {
GURL original_url;
EraseHandle erase_handle;
};
// The cache will hold at most `max_size` entries. Each entry stores the query
// parameter from a previous response, which will typically be 100 to 200
// bytes.
explicit NoVarySearchCache(size_t max_size);
// Not copyable, assignable or movable.
NoVarySearchCache(const NoVarySearchCache&) = delete;
NoVarySearchCache& operator=(const NoVarySearchCache&) = delete;
~NoVarySearchCache();
// Finds an entry in the cache equivalent to `url`. If a result is returned,
// then `original_url` can be used to find a disk cache entry. `erase_handle`
// can be used to remove the entry from this cache if it was not in the disk
// cache. Not const because it updates the LRU linked list to mark the entry
// as recently used.
std::optional<LookupResult> Lookup(const NetworkIsolationKey& nik,
const GURL& url);
// Inserts `url` into the cache if a non-default "No-Vary-Search" header was
// found in `headers`. On insertion, will remove any existing matching entry
// with the same No-Vary-Search header, as the older entry would never be
// returned by Lookup() anyway. May evict the oldest entry in the cache to
// avoid the size exceeding `max_size_`.
void MaybeInsert(const NetworkIsolationKey& nik,
const GURL& url,
const HttpResponseHeaders& headers);
// Erases the entry referenced by `erase_handle` from the cache. Does nothing
// if the entry no longer exists.
void Erase(EraseHandle handle);
// Returns the size (number of stored original query strings) of the cache.
size_t GetSizeForTesting() const;
// Returns true if the top-level map is empty. This should be equivalent to
// GetSizeForTesting() == 0 in the absence of bugs.
bool IsTopLevelMapEmptyForTesting() const;
private:
class LruNode;
class QueryStringListNode;
struct Key {
NetworkIsolationKey nik;
GURL base_url;
bool operator==(const Key& rhs) const;
};
// KeyReference allows us to do a map lookup without constructing a GURL and a
// Key.
using KeyReference = std::pair<const NetworkIsolationKey&, std::string_view>;
struct KeyComparator {
using is_transparent = void;
bool operator()(const Key& lhs, const Key& rhs) const;
bool operator()(const Key& lhs, const KeyReference& rhs) const;
bool operator()(const KeyReference& lhs, const Key& rhs) const;
};
friend bool operator==(const Key& lhs, const KeyReference& rhs) {
return lhs.nik == rhs.first && lhs.base_url == rhs.second;
}
struct QueryStringList {
base::LinkedList<QueryStringListNode> list;
// nvs_data_ref can't be raw_ref because it needs to be lazily initialized
// after the QueryStringList has been added to the map.
raw_ptr<const HttpNoVarySearchData> nvs_data_ref;
raw_ref<const Key> key_ref;
explicit QueryStringList(const Key& key);
// base::LinkedList<> does not do memory management, so make sure the
// constents of `list` are deleted on destruction.
~QueryStringList();
};
struct FindQueryStringResult {
STACK_ALLOCATED(); // `match` doesn't need to be raw_ptr.
public:
QueryString* match;
GURL original_url;
};
// TODO(crbug.com/382394774): Investigate performance of different map types.
using DataMapType = std::map<HttpNoVarySearchData, QueryStringList>;
using OuterMapType = std::map<Key, DataMapType, KeyComparator>;
// Erases an entry from the cache if it is full;
void EvictIfFull();
// Erases `query_string` from the cache.
void EraseQuery(QueryString* query_string);
static std::optional<FindQueryStringResult> FindQueryStringInList(
QueryStringList& query_strings,
std::string_view base,
const GURL& url,
const HttpNoVarySearchData& nvs_data);
// The main cache data structure.
OuterMapType map_;
// lru_.tail() is the least-recently-used QueryString.
base::LinkedList<LruNode> lru_;
// The number of QueryString objects in the cache.
size_t size_ = 0u;
// QueryString objects will be evicted to avoid exceeding `max_size_`.
const size_t max_size_;
};
} // namespace net
#endif // NET_HTTP_NO_VARY_SEARCH_CACHE_H_

View File

@ -0,0 +1,517 @@
// 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 "net/http/no_vary_search_cache.h"
#include <array>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include "base/threading/platform_thread.h"
#include "base/time/time.h"
#include "net/base/network_isolation_key.h"
#include "net/base/schemeful_site.h"
#include "net/http/http_response_headers.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
namespace net {
namespace {
constexpr size_t kMaxSize = 5;
GURL TestURL(std::string_view query = {}) {
GURL url("https://example.com/");
if (query.empty()) {
return url;
}
GURL::Replacements replacements;
replacements.SetQueryStr(query);
return url.ReplaceComponents(replacements);
}
NetworkIsolationKey TestNIK() {
SchemefulSite site(TestURL());
return NetworkIsolationKey(site, site);
}
scoped_refptr<HttpResponseHeaders> TestHeaders(
std::string_view no_vary_search) {
return HttpResponseHeaders::Builder({1, 1}, "200 OK")
.AddHeader("No-Vary-Search", no_vary_search)
.Build();
}
class NoVarySearchCacheTest : public ::testing::Test {
protected:
NoVarySearchCache& cache() { return cache_; }
// Inserts a URL with query `query` into cache with a No-Vary-Search header
// value of `no_vary_search`.
void Insert(std::string_view query, std::string_view no_vary_search) {
cache_.MaybeInsert(TestNIK(), TestURL(query), *TestHeaders(no_vary_search));
}
// Returns true if TestURL(query) exists in cache.
bool Exists(std::string_view query) {
return cache_.Lookup(TestNIK(), TestURL(query)).has_value();
}
private:
NoVarySearchCache cache_{kMaxSize};
};
TEST_F(NoVarySearchCacheTest, NewlyConstructedCacheIsEmpty) {
EXPECT_EQ(cache().GetSizeForTesting(), 0u);
}
TEST_F(NoVarySearchCacheTest, LookupOnEmptyCache) {
EXPECT_EQ(cache().Lookup(TestNIK(), TestURL()), std::nullopt);
}
TEST_F(NoVarySearchCacheTest, InsertLookupErase) {
Insert("", "key-order");
auto result = cache().Lookup(TestNIK(), TestURL());
ASSERT_TRUE(result);
EXPECT_EQ(result->original_url, TestURL());
EXPECT_EQ(cache().GetSizeForTesting(), 1u);
cache().Erase(std::move(result->erase_handle));
EXPECT_EQ(cache().GetSizeForTesting(), 0u);
EXPECT_TRUE(cache().IsTopLevelMapEmptyForTesting());
}
// An asan build will find leaks, but this test works on any build.
TEST_F(NoVarySearchCacheTest, QueryNotLeaked) {
std::optional<NoVarySearchCache::LookupResult> result;
{
NoVarySearchCache cache(kMaxSize);
cache.MaybeInsert(TestNIK(), TestURL(), *TestHeaders("params"));
result = cache.Lookup(TestNIK(), TestURL());
ASSERT_TRUE(result);
EXPECT_FALSE(result->erase_handle.IsGoneForTesting());
}
EXPECT_TRUE(result->erase_handle.IsGoneForTesting());
}
TEST_F(NoVarySearchCacheTest, OldestItemIsEvicted) {
for (size_t i = 0; i < kMaxSize + 1; ++i) {
std::string query = "i=" + base::NumberToString(i);
Insert(query, "params, except=(\"i\")");
EXPECT_TRUE(Exists(query));
}
EXPECT_EQ(cache().GetSizeForTesting(), kMaxSize);
EXPECT_FALSE(Exists("i=0"));
}
TEST_F(NoVarySearchCacheTest, RecentlyUsedItemIsNotEvicted) {
for (size_t i = 0; i < kMaxSize + 1; ++i) {
std::string query = "i=" + base::NumberToString(i);
Insert(query, "params, except=(\"i\")");
EXPECT_TRUE(Exists(query));
// Exists() calls Lookup(), which makes an entry "used".
EXPECT_TRUE(Exists("i=0"));
}
EXPECT_EQ(cache().GetSizeForTesting(), kMaxSize);
EXPECT_TRUE(Exists("i=0"));
EXPECT_FALSE(Exists("i=1"));
}
TEST_F(NoVarySearchCacheTest, MostRecentlyUsedItemIsNotEvicted) {
static constexpr char kNoVarySearchValue[] = "params, except=(\"i\")";
const auto query = [](int i) { return "i=" + base::NumberToString(i); };
// Fill the cache.
for (size_t i = 0; i < kMaxSize; ++i) {
Insert(query(i), kNoVarySearchValue);
}
EXPECT_EQ(cache().GetSizeForTesting(), kMaxSize);
// Make "i=3" be the most recently used item.
EXPECT_TRUE(Exists("i=3"));
// Evict kMaxSize - 1 items.
for (size_t i = kMaxSize; i < kMaxSize * 2 - 1; ++i) {
Insert(query(i), kNoVarySearchValue);
EXPECT_TRUE(Exists(query(i)));
}
EXPECT_EQ(cache().GetSizeForTesting(), kMaxSize);
EXPECT_TRUE(Exists("i=3"));
}
TEST_F(NoVarySearchCacheTest, LeastRecentlyUsedItemIsEvicted) {
static constexpr char kNoVarySearchValue[] = "params, except=(\"i\")";
const auto query = [](int i) { return "i=" + base::NumberToString(i); };
// Fill the cache.
for (size_t i = 0; i < kMaxSize; ++i) {
Insert(query(i), kNoVarySearchValue);
}
EXPECT_EQ(cache().GetSizeForTesting(), kMaxSize);
// Make "i=kMaxSize-1" be the least recently used item.
for (size_t i = 0; i < kMaxSize - 1; ++i) {
EXPECT_TRUE(Exists(query(i)));
}
// Evict one item.
Insert(query(kMaxSize), kNoVarySearchValue);
// Verify it was the least-recently-used item.
EXPECT_FALSE(Exists(query(kMaxSize - 1)));
}
TEST_F(NoVarySearchCacheTest, InsertUpdatesIdenticalItem) {
Insert("a=b", "params=(\"c\")");
auto original_result = cache().Lookup(TestNIK(), TestURL("a=b"));
ASSERT_TRUE(original_result);
Insert("a=b", "params=(\"c\")");
auto new_result = cache().Lookup(TestNIK(), TestURL("a=b"));
ASSERT_TRUE(new_result);
EXPECT_TRUE(
original_result->erase_handle.EqualsForTesting(new_result->erase_handle));
}
TEST_F(NoVarySearchCacheTest, InsertRemovesMatchingItem) {
Insert("a=b&c=1", "params=(\"c\")");
auto original_result = cache().Lookup(TestNIK(), TestURL("a=b"));
ASSERT_TRUE(original_result);
EXPECT_EQ(original_result->original_url, TestURL("a=b&c=1"));
Insert("a=b&c=2", "params=(\"c\")");
EXPECT_TRUE(original_result->erase_handle.IsGoneForTesting());
EXPECT_EQ(cache().GetSizeForTesting(), 1u);
auto new_result = cache().Lookup(TestNIK(), TestURL("a=b"));
EXPECT_EQ(new_result->original_url, TestURL("a=b&c=2"));
}
TEST_F(NoVarySearchCacheTest, MaybeInsertDoesNothingWithNoNoVarySearchHeader) {
auto headers = HttpResponseHeaders::Builder({1, 1}, "200 OK").Build();
cache().MaybeInsert(TestNIK(), TestURL(), *headers);
EXPECT_EQ(cache().GetSizeForTesting(), 0u);
EXPECT_TRUE(cache().IsTopLevelMapEmptyForTesting());
}
TEST_F(NoVarySearchCacheTest, MaybeInsertDoesNothingForDefaultBehavior) {
// The following header values are all equivalent to default behavior.
static constexpr auto kDefaultCases = std::to_array<std::string_view>(
{"", " ", "params=?0", "params=()", "key-order=?0", "nonsense"});
for (auto no_vary_search : kDefaultCases) {
NoVarySearchCache cache(kMaxSize);
Insert("a=b", no_vary_search);
EXPECT_EQ(cache.GetSizeForTesting(), 0u) << no_vary_search;
}
}
TEST_F(NoVarySearchCacheTest, MatchesWithoutQueryString) {
auto url_with_query = GURL("https://example.com/foo?");
cache().MaybeInsert(TestNIK(), url_with_query, *TestHeaders("key-order"));
auto result = cache().Lookup(TestNIK(), GURL("https://example.com/foo"));
ASSERT_TRUE(result);
EXPECT_EQ(result->original_url, url_with_query);
}
TEST_F(NoVarySearchCacheTest, InsertInvalidURLIsIgnored) {
auto invalid_url = GURL("???");
ASSERT_FALSE(invalid_url.is_valid());
cache().MaybeInsert(TestNIK(), invalid_url, *TestHeaders("key-order"));
EXPECT_EQ(cache().GetSizeForTesting(), 0u);
}
// There's no way to insert an invalid URL into the cache. There's also no way
// to add a query to a valid URL to make it invalid. So this test just verifies
// that we don't crash.
TEST_F(NoVarySearchCacheTest, LookupInvalidURLReturnsNullopt) {
GURL invalid_url = GURL("???");
ASSERT_FALSE(invalid_url.is_valid());
auto result = cache().Lookup(TestNIK(), invalid_url);
EXPECT_FALSE(result);
}
bool Matches(std::string_view insert,
std::string_view lookup,
std::string_view no_vary_search) {
NoVarySearchCache cache(kMaxSize);
cache.MaybeInsert(TestNIK(), TestURL(insert), *TestHeaders(no_vary_search));
EXPECT_EQ(cache.GetSizeForTesting(), 1u);
const auto exists = [&cache](std::string_view query) {
return cache.Lookup(TestNIK(), TestURL(query)).has_value();
};
// It would be bad if the query didn't match itself.
EXPECT_TRUE(exists(insert));
return exists(lookup);
}
bool InsertMatches(std::string_view insert1,
std::string_view insert2,
std::string_view no_vary_search) {
NoVarySearchCache cache(kMaxSize);
const auto insert = [&cache, no_vary_search](std::string_view query) {
cache.MaybeInsert(TestNIK(), TestURL(query), *TestHeaders(no_vary_search));
};
insert(insert1);
EXPECT_EQ(cache.GetSizeForTesting(), 1u);
insert(insert2);
return cache.GetSizeForTesting() == 1u;
}
TEST_F(NoVarySearchCacheTest, MatchCases) {
struct Case {
std::string_view description;
std::string_view query1;
std::string_view query2;
std::string_view no_vary_search;
};
static constexpr Case cases[] = {
{"Encoded & in key", "%26=a&b=c", "b=c", "params=(\"&\")"},
{"Encoded & in value", "a=b%26&c=d", "c=d&a=b%26", "key-order"},
{"Encoded =", "%3d=a", "%3D=a", "key-order"},
{"Encoded and unencoded =", "a=%3d", "a==", "key-order"},
{"Embedded null in key", "a%00b=c", "", "params=(\"a%00b\")"},
{"Embedded null in value", "a=b%00c", "a=b%00c", "key-order"},
{"Encoded space in key", "+=a", "%20=b", "params=(\" \")"},
{"Encoded space in value", "a=b&c=+", "c=%20&a=b", "key-order"},
{"Key is ?", "?=1", "", "params=(\"?\")"},
{"Empty key", "=7&c=d", "c=d", "params=(\"\")"},
{"Empty value", "a=&c=d", "c=d&a", "key-order"},
{"Bad UTF8", "%fe=%ff", "\xfe=\xff", "key-order"},
{"Two params removed", "a=b&c=d&e", "e", R"(params=("a" "c"))"},
};
for (const auto& [description, query1, query2, no_vary_search] : cases) {
EXPECT_TRUE(Matches(query1, query2, no_vary_search))
<< "Testing forwards: " << description;
EXPECT_TRUE(Matches(query2, query1, no_vary_search))
<< "Testing backwards: " << description;
EXPECT_TRUE(InsertMatches(query1, query2, no_vary_search))
<< "Testing double insert: " << description;
}
}
TEST_F(NoVarySearchCacheTest, NoMatchCases) {
struct Case {
std::string_view description;
std::string_view query1;
std::string_view query2;
};
static constexpr Case cases[] = {
{"Encoded &", "a&b", "a%26b"},
{"Encoded =", "a=b", "a%3db"},
};
static constexpr std::string_view kKeyOrder = "key-order";
for (const auto& [description, query1, query2] : cases) {
EXPECT_FALSE(Matches(query1, query2, kKeyOrder))
<< "Testing forwards: " << description;
EXPECT_FALSE(Matches(query2, query1, kKeyOrder))
<< "Testing backwards: " << description;
EXPECT_FALSE(InsertMatches(query1, query2, kKeyOrder))
<< "Testing double insert: " << description;
}
}
// Different representations of No-Very-Search headers that should compare
// equal.
TEST_F(NoVarySearchCacheTest, NoVarySearchVariants) {
struct Case {
std::string_view description;
std::string_view variant1;
std::string_view variant2;
};
static constexpr Case cases[] = {
{"Extra space", R"(params=("a" "b"))", R"(params=( "a" "b" ))"},
{"Bool or omitted params", "params", "params=?1"},
{"Bool or omitted key-order", "key-order", "key-order=?1"},
{"Absent or false key-order", "params, key-order=?0", "params"},
{"Ignored entry", "params, ignored", "params"},
{"Empty except", "params, except=()", "except=(), params"},
{"Different order", R"(params, except=("a"), key-order)",
R"(key-order, except=("a"), params)"},
};
static constexpr std::string_view kQuery = "a=b&b=7&c=d";
for (const auto& [description, variant1, variant2] : cases) {
NoVarySearchCache cache(kMaxSize);
cache.MaybeInsert(TestNIK(), TestURL(kQuery), *TestHeaders(variant1));
EXPECT_EQ(cache.GetSizeForTesting(), 1u);
cache.MaybeInsert(TestNIK(), TestURL(kQuery), *TestHeaders(variant2));
EXPECT_EQ(cache.GetSizeForTesting(), 1u)
<< "Failing: " << description << "; variant1='" << variant1
<< "'; variant2 = '" << variant2 << "'";
}
}
// Items with a transient NIK will not be stored in the disk cache, and so they
// shouldn't be stored in the NoVarySearchCache either.
TEST_F(NoVarySearchCacheTest, TransientNIK) {
const auto transient = NetworkIsolationKey::CreateTransientForTesting();
cache().MaybeInsert(transient, TestURL(), *TestHeaders("params"));
EXPECT_EQ(cache().GetSizeForTesting(), 0u);
EXPECT_EQ(cache().Lookup(transient, TestURL()), std::nullopt);
}
TEST_F(NoVarySearchCacheTest, DifferentNIK) {
const NetworkIsolationKey nik1 = TestNIK();
const NetworkIsolationKey nik2(
SchemefulSite(TestURL()),
SchemefulSite(GURL("https://thirdparty.example/")));
cache().MaybeInsert(nik1, TestURL(), *TestHeaders("params"));
cache().MaybeInsert(nik2, TestURL(), *TestHeaders("params"));
EXPECT_EQ(cache().GetSizeForTesting(), 2u);
const auto result1 = cache().Lookup(nik1, TestURL());
const auto result2 = cache().Lookup(nik2, TestURL());
ASSERT_TRUE(result1);
ASSERT_TRUE(result2);
EXPECT_FALSE(result1->erase_handle.EqualsForTesting(result2->erase_handle));
}
TEST_F(NoVarySearchCacheTest, DifferentURL) {
const GURL url1("https://example.com/a?a=b");
const GURL url2("https://example.com/b?a=b");
cache().MaybeInsert(TestNIK(), url1, *TestHeaders("key-order"));
cache().MaybeInsert(TestNIK(), url2, *TestHeaders("key-order"));
EXPECT_EQ(cache().GetSizeForTesting(), 2u);
const auto result1 = cache().Lookup(TestNIK(), url1);
const auto result2 = cache().Lookup(TestNIK(), url2);
ASSERT_TRUE(result1);
ASSERT_TRUE(result2);
EXPECT_FALSE(result1->erase_handle.EqualsForTesting(result2->erase_handle));
}
void SpinUntilCurrentTimeChanges() {
const auto start = base::Time::Now();
while (start == base::Time::Now()) {
base::PlatformThread::YieldCurrentThread();
}
}
TEST_F(NoVarySearchCacheTest, DifferentNoVarySearch) {
Insert("a=b&c=d", "params, except=(\"a\")");
// Make sure that the two inserts reliably get a different `inserted`
// timestamp so that the ordering is deterministic.
SpinUntilCurrentTimeChanges();
Insert("a=b", "key-order");
EXPECT_EQ(cache().GetSizeForTesting(), 2u);
const auto result = cache().Lookup(TestNIK(), TestURL("a=b"));
ASSERT_TRUE(result);
// If time goes backwards this test will flake.
EXPECT_EQ(result->original_url, TestURL("a=b"));
}
// The winner of the lookup should depend only on insertion order and not on the
// order of iteration of the map. To ensure this works for any iteration order,
// we perform the same test in the opposite direction.
TEST_F(NoVarySearchCacheTest, DifferentNoVarySearchReverseOrder) {
Insert("a=b", "key-order");
SpinUntilCurrentTimeChanges();
Insert("a=b&c=d", "params, except=(\"a\")");
EXPECT_EQ(cache().GetSizeForTesting(), 2u);
const auto result = cache().Lookup(TestNIK(), TestURL("a=b"));
ASSERT_TRUE(result);
// If time goes backwards this test will flake.
EXPECT_EQ(result->original_url, TestURL("a=b&c=d"));
}
TEST_F(NoVarySearchCacheTest, EraseInDifferentOrder) {
// Insert in order a, b, c.
Insert("a", "key-order");
Insert("b", "key-order");
Insert("c", "key-order");
// Look up in order b, c, a.
const auto lookup = [this](std::string_view query) {
return cache().Lookup(TestNIK(), TestURL(query));
};
auto result_b = lookup("b");
auto result_c = lookup("c");
auto result_a = lookup("a");
ASSERT_TRUE(result_a);
ASSERT_TRUE(result_b);
ASSERT_TRUE(result_c);
// Erase in order c, a, b.
cache().Erase(std::move(result_c->erase_handle));
EXPECT_TRUE(Exists("a"));
EXPECT_TRUE(Exists("b"));
EXPECT_FALSE(Exists("c"));
cache().Erase(std::move(result_a->erase_handle));
EXPECT_FALSE(Exists("a"));
EXPECT_TRUE(Exists("b"));
cache().Erase(std::move(result_b->erase_handle));
EXPECT_FALSE(Exists("b"));
EXPECT_EQ(cache().GetSizeForTesting(), 0u);
EXPECT_TRUE(cache().IsTopLevelMapEmptyForTesting());
}
// The URL "ref", also known as the "fragment", also known as the "hash", is
// ignored for matching and not stored in the cache.
TEST_F(NoVarySearchCacheTest, URLRefIsIgnored) {
cache().MaybeInsert(TestNIK(), GURL("https://example.com/?a=b#foo"),
*TestHeaders("key-order"));
cache().MaybeInsert(TestNIK(), GURL("https://example.com/?a=b#bar"),
*TestHeaders("key-order"));
EXPECT_EQ(cache().GetSizeForTesting(), 1u);
auto result = cache().Lookup(TestNIK(), GURL("https://example.com/?a=b#baz"));
EXPECT_TRUE(result);
EXPECT_EQ(result->original_url, GURL("https://example.com/?a=b"));
}
TEST_F(NoVarySearchCacheTest, URLWithUsernameIsRejected) {
const GURL url_with_username("https://me@example.com/?a=b");
cache().MaybeInsert(TestNIK(), url_with_username, *TestHeaders("key-order"));
EXPECT_EQ(cache().GetSizeForTesting(), 0u);
// See if it matches against the URL without the username.
cache().MaybeInsert(TestNIK(), GURL("https://example.com/?a=b"),
*TestHeaders("key-order"));
EXPECT_FALSE(cache().Lookup(TestNIK(), url_with_username));
}
TEST_F(NoVarySearchCacheTest, URLWithPasswordIsRejected) {
const GURL url_with_password("https://:hunter123@example.com/?a=b");
cache().MaybeInsert(TestNIK(), url_with_password, *TestHeaders("key-order"));
EXPECT_EQ(cache().GetSizeForTesting(), 0u);
// See if it matches against the URL without the password.
cache().MaybeInsert(TestNIK(), GURL("https://example.com/?a=b"),
*TestHeaders("key-order"));
EXPECT_FALSE(cache().Lookup(TestNIK(), url_with_password));
}
} // namespace
} // namespace net

View File

@ -1874,6 +1874,42 @@ Called by update_net_trust_anchors.py.-->
<int value="2" label="Metered"/>
</enum>
<!-- LINT.IfChange(NoVarySearchHeaderParseResult) -->
<enum name="NoVarySearchHeaderParseResult">
<int value="0" label="Success">
The No-Vary-Search header was successfully parsed.
</int>
<int value="1" label="No header">
The No-Vary-Search header was not present in the response headers.
</int>
<int value="2" label="Default value">
The No-Vary-Search header value was equivalent to the default (vary on all
query parameters), and so was ignored.
</int>
<int value="3" label="Not dictionary">
The value of the No-Vary-Search header could not be parsed as a structured
headers dictionary.
</int>
<int value="4" label="Non-boolean key-order">
The &quot;key-order&quot; parameter does not have a boolean value.
</int>
<int value="5" label="Params not string list">
The &quot;params&quot; parameter is not an inner list, or contains a
non-string value.
</int>
<int value="6" label="Except not string list">
The &quot;except&quot; parameter is not an inner list, or contains a
non-string value.
</int>
<int value="7" label="Except without true params">
The &quot;except&quot; parameter was supplied, but the &quot;params&quot;
parameter did not have a boolean true value.
</int>
</enum>
<!-- LINT.ThenChange(//net/http/http_no_vary_search_data.h:NoVarySearchHeaderParseResult) -->
<enum name="NsswitchService">
<int value="0" label="Unknown"/>
<int value="1" label="&quot;files&quot;"/>

View File

@ -477,6 +477,17 @@ chromium-metrics-reviews@google.com.
</summary>
</histogram>
<histogram name="HttpCache.NoVarySearch.HeaderParseResult"
enum="NoVarySearchHeaderParseResult" expires_after="2025-12-16">
<owner>ricea@chromium.org</owner>
<owner>net-dev@chromium.org</owner>
<summary>
The result of parsing the No-Vary-Search value in the HTTP response header.
This metric is emitted every time an HTTP response is written to the HTTP
disk cache.
</summary>
</histogram>
<histogram name="HttpCache.OpenDiskEntry" units="ms" expires_after="2025-07-13">
<owner>yhirano@chromium.org</owner>
<owner>net-dev@chromium.org</owner>