Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ extensions/filters/common/original_src @snowp @klarose
/*/extensions/filters/http/dynamic_forward_proxy @mattklein123 @alyssawilk
# omit_canary_hosts retry predicate
/*/extensions/retry/host/omit_canary_hosts @sriduth @snowp
# HTTP caching extension
/*/extensions/filters/http/cache @toddmgreer @jmarantz
# aws_iam grpc credentials
/*/extensions/grpc_credentials/aws_iam @lavignes @mattklein123
/*/extensions/filters/http/common/aws @lavignes @mattklein123
Expand Down
21 changes: 21 additions & 0 deletions source/extensions/filters/http/cache/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
licenses(["notice"]) # Apache 2

## Pluggable HTTP cache filter

load(
"//bazel:envoy_build_system.bzl",
"envoy_cc_library",
"envoy_package",
)

envoy_package()

envoy_cc_library(
name = "http_cache_utils_lib",
srcs = ["http_cache_utils.cc"],
hdrs = ["http_cache_utils.h"],
deps = [
"//include/envoy/common:time_interface",
"//include/envoy/http:header_map_interface",
],
)
188 changes: 188 additions & 0 deletions source/extensions/filters/http/cache/http_cache_utils.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#include "extensions/filters/http/cache/http_cache_utils.h"

#include <array>
#include <string>

#include "absl/algorithm/container.h"
#include "absl/strings/ascii.h"
#include "absl/strings/numbers.h"
#include "absl/strings/strip.h"

namespace Envoy {
namespace Extensions {
namespace HttpFilters {
namespace Cache {

// True for characters defined as tchars by
// https://tools.ietf.org/html/rfc7230#section-3.2.6
//
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+"
// / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
bool Utils::tchar(char c) {
switch (c) {
case '!':
case '#':
case '$':
case '%':
case '&':
case '*':
case '+':
case '-':
case '.':
case '^':
case '_':
case '`':
case '|':
case '~':
return true;
}
return absl::ascii_isalnum(c);
}

// Removes an initial HTTP header field value token, as defined by
// https://tools.ietf.org/html/rfc7230#section-3.2.6. Returns true if an initial
// token was present.
//
// token = 1*tchar
bool Utils::eatToken(absl::string_view& s) {
const absl::string_view::iterator token_end = c_find_if_not(s, &tchar);
if (token_end == s.begin()) {
return false;
}
s.remove_prefix(token_end - s.begin());
return true;
}

// Removes an initial token or quoted-string (if present), as defined by
// https://tools.ietf.org/html/rfc7234#section-5.2. If a cache-control directive
// has an argument (as indicated by '='), it should be in this form.
//
// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
// qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text
// obs-text = %x80-FF
// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
// VCHAR = %x21-7E ; visible (printing) characters
//
// For example, the directive "my-extension=42" has an argument of "42", so an
// input of "public, my-extension=42, max-age=999"
void Utils::eatDirectiveArgument(absl::string_view& s) {
if (s.empty()) {
return;
}
if (s.front() == '"') {
// TODO(#9833): handle \-escaped quotes
const size_t closing_quote = s.find('"', 1);
s.remove_prefix(closing_quote);
} else {
eatToken(s);
}
}

// If s is non-null and begins with a decimal number ([0-9]+), removes it from
// the input and returns a SystemTime::duration representing that many seconds.
// If s is null or doesn't begin with digits, returns
// SystemTime::duration::zero(). If parsing overflows, returns
// SystemTime::duration::max().
SystemTime::duration Utils::eatLeadingDuration(absl::string_view& s) {
const absl::string_view::iterator digits_end = c_find_if_not(s, &absl::ascii_isdigit);
const size_t digits_length = digits_end - s.begin();
if (digits_length == 0) {
return SystemTime::duration::zero();
}
const absl::string_view digits(s.begin(), digits_length);
s.remove_prefix(digits_length);
uint64_t num;
return absl::SimpleAtoi(digits, &num) ? std::chrono::seconds(num) : SystemTime::duration::max();
}

// Returns the effective max-age represented by cache-control. If the result is
// SystemTime::duration::zero(), or is less than the response's, the response
// should be validated.
//
// TODO(#9833): Write a CacheControl class to fully parse the cache-control
// header value. Consider sharing with the gzip filter.
SystemTime::duration Utils::effectiveMaxAge(absl::string_view cache_control) {
// The grammar for This Cache-Control header value should be:
// Cache-Control = 1#cache-directive
// cache-directive = token [ "=" ( token / quoted-string ) ]
// token = 1*tchar
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+"
// / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
// qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text
// obs-text = %x80-FF
// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
// VCHAR = %x21-7E ; visible (printing) characters
SystemTime::duration max_age = SystemTime::duration::zero();
bool found_s_maxage = false;
while (!cache_control.empty()) {
// Each time through the loop, we eat one cache-directive. Each branch
// either returns or completely eats a cache-directive.
if (ConsumePrefix(&cache_control, "no-cache")) {
if (eatToken(cache_control)) {
// The token wasn't no-cache; it just started that way, so we must
// finish eating this cache-directive.
if (ConsumePrefix(&cache_control, "=")) {
eatDirectiveArgument(cache_control);
}
} else {
// Found a no-cache directive, so validation is required.
return SystemTime::duration::zero();
}
} else if (ConsumePrefix(&cache_control, "s-maxage=")) {
max_age = eatLeadingDuration(cache_control);
found_s_maxage = true;
cache_control = StripLeadingAsciiWhitespace(cache_control);
if (!cache_control.empty() && cache_control[0] != ',') {
// Unexpected text at end of directive
return SystemTime::duration::zero();
}
} else if (!found_s_maxage && ConsumePrefix(&cache_control, "max-age=")) {
max_age = eatLeadingDuration(cache_control);
if (!cache_control.empty() && cache_control[0] != ',') {
// Unexpected text at end of directive
return SystemTime::duration::zero();
}
} else if (eatToken(cache_control)) {
// Unknown directive--ignore.
if (ConsumePrefix(&cache_control, "=")) {
eatDirectiveArgument(cache_control);
}
} else {
// This directive starts with illegal characters. Require validation.
return SystemTime::duration::zero();
}
// Whichever branch we took should have consumed the entire cache-directive,
// so we just need to eat the delimiter and optional whitespace.
ConsumePrefix(&cache_control, ",");
cache_control = StripLeadingAsciiWhitespace(cache_control);
}
return max_age;
}

SystemTime Utils::httpTime(const Http::HeaderEntry* header_entry) {
if (!header_entry) {
return {};
}
absl::Time time;
const std::string input(header_entry->value().getStringView());

// Acceptable Date/Time Formats per
// https://tools.ietf.org/html/rfc7231#section-7.1.1.1
//
// Sun, 06 Nov 1994 08:49:37 GMT ; IMF-fixdate
// Sunday, 06-Nov-94 08:49:37 GMT ; obsolete RFC 850 format
// Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format
static const auto& rfc7231_date_formats = *new std::array<std::string, 3>{
"%a, %d %b %Y %H:%M:%S GMT", "%A, %d-%b-%y %H:%M:%S GMT", "%a %b %e %H:%M:%S %Y"};
for (const std::string& format : rfc7231_date_formats) {
if (absl::ParseTime(format, input, &time, nullptr)) {
return ToChronoTime(time);
}
}
return {};
}
} // namespace Cache
} // namespace HttpFilters
} // namespace Extensions
} // namespace Envoy
34 changes: 34 additions & 0 deletions source/extensions/filters/http/cache/http_cache_utils.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#pragma once

#include "envoy/common/time.h"
#include "envoy/http/header_map.h"

#include "absl/strings/string_view.h"
#include "absl/time/time.h"

namespace Envoy {
namespace Extensions {
namespace HttpFilters {
namespace Cache {
class Utils {
public:
// Parses and returns max-age or s-maxage (with s-maxage taking precedence),
// parsed into a SystemTime::Duration. Returns SystemTime::Duration::zero if
// neither is present, or there is a no-cache directive, or if max-age or
// s-maxage is malformed.
static SystemTime::duration effectiveMaxAge(absl::string_view cache_control);

// Parses header_entry as an HTTP time. Returns SystemTime() if
// header_entry is null or malformed.
static SystemTime httpTime(const Http::HeaderEntry* header_entry);

private:
static bool tchar(char c);
static bool eatToken(absl::string_view& s);
static void eatDirectiveArgument(absl::string_view& s);
static SystemTime::duration eatLeadingDuration(absl::string_view& s);
};
} // namespace Cache
} // namespace HttpFilters
} // namespace Extensions
} // namespace Envoy
17 changes: 17 additions & 0 deletions test/extensions/filters/http/cache/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
licenses(["notice"]) # Apache 2

load("//bazel:envoy_build_system.bzl", "envoy_cc_test", "envoy_package")

envoy_package()

# TODO(toddmgreer) Change to envoy_extension_cc_test when adding the full filter.
envoy_cc_test(
name = "http_cache_utils_test",
srcs = ["http_cache_utils_test.cc"],
deps = [
"//include/envoy/http:header_map_interface",
"//source/common/http:header_map_lib",
"//source/extensions/filters/http/cache:http_cache_utils_lib",
"//test/test_common:utility_lib",
],
)
81 changes: 81 additions & 0 deletions test/extensions/filters/http/cache/http_cache_utils_test.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#include <vector>

#include "common/http/header_map_impl.h"

#include "extensions/filters/http/cache/http_cache_utils.h"

#include "test/test_common/utility.h"

#include "gtest/gtest.h"

namespace Envoy {
namespace Extensions {
namespace HttpFilters {
namespace Cache {
namespace {

// TODO(#9872): Add tests for eat* functions
// TODO(#9872): More tests for httpTime, effectiveMaxAge

class HttpTimeTest : public testing::TestWithParam<const char*> {};

const char* const ok_times[] = {
"Sun, 06 Nov 1994 08:49:37 GMT", // IMF-fixdate
"Sunday, 06-Nov-94 08:49:37 GMT", // obsolete RFC 850 format
"Sun Nov 6 08:49:37 1994" // ANSI C's asctime() format
};

INSTANTIATE_TEST_SUITE_P(Ok, HttpTimeTest, testing::ValuesIn(ok_times));

TEST_P(HttpTimeTest, Ok) {
Http::TestHeaderMapImpl response_headers{{"date", GetParam()}};
// Manually confirmed that 784111777 is 11/6/94, 8:46:37.
EXPECT_EQ(784111777, SystemTime::clock::to_time_t(Utils::httpTime(response_headers.Date())));
}

TEST(HttpTime, Null) { EXPECT_EQ(Utils::httpTime(nullptr), SystemTime()); }

struct EffectiveMaxAgeParams {
absl::string_view cache_control;
int effective_max_age_secs;
};

EffectiveMaxAgeParams params[] = {
{"public, max-age=3600", 3600},
{"public, max-age=-1", 0},
{"max-age=20", 20},
{"max-age=86400, public", 86400},
{"public,max-age=\"0\"", 0},
{"public,max-age=8", 8},
{"public,max-age=3,no-cache", 0},
{"s-maxage=0", 0},
{"max-age=10,s-maxage=0", 0},
{"s-maxage=10", 10},
{"no-cache", 0},
{"max-age=0", 0},
{"no-cache", 0},
{"public", 0},
// TODO(#9833): parse quoted forms
// {"max-age=20, s-maxage=\"25\"",25},
// {"public,max-age=\"8\",foo=11",8},
// {"public,max-age=\"8\",bar=\"11\"",8},
// TODO(#9833): parse public/private
// {"private,max-age=10",0}
// {"private",0},
// {"private,s-maxage=8",0},
};

class EffectiveMaxAgeTest : public testing::TestWithParam<EffectiveMaxAgeParams> {};

INSTANTIATE_TEST_SUITE_P(EffectiveMaxAgeTest, EffectiveMaxAgeTest, testing::ValuesIn(params));

TEST_P(EffectiveMaxAgeTest, EffectiveMaxAgeTest) {
EXPECT_EQ(Utils::effectiveMaxAge(GetParam().cache_control),
std::chrono::seconds(GetParam().effective_max_age_secs));
}

} // namespace
} // namespace Cache
} // namespace HttpFilters
} // namespace Extensions
} // namespace Envoy
Loading