| // Copyright 2019 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "components/web_package/web_bundle_parser.h" |
| |
| #include <algorithm> |
| #include <optional> |
| #include <string_view> |
| #include <variant> |
| |
| #include "base/compiler_specific.h" |
| #include "base/containers/span.h" |
| #include "base/files/file_path.h" |
| #include "base/files/file_util.h" |
| #include "base/path_service.h" |
| #include "base/rand_util.h" |
| #include "base/test/gmock_expected_support.h" |
| #include "base/test/task_environment.h" |
| #include "base/test/test_future.h" |
| #include "base/types/expected.h" |
| #include "base/types/expected_macros.h" |
| #include "components/cbor/writer.h" |
| #include "components/web_package/mojom/web_bundle_parser.mojom.h" |
| #include "components/web_package/signed_web_bundles/ed25519_public_key.h" |
| #include "components/web_package/signed_web_bundles/integrity_block_attributes.h" |
| #include "components/web_package/signed_web_bundles/signed_web_bundle_id.h" |
| #include "components/web_package/test_support/signed_web_bundles/ed25519_key_pair.h" |
| #include "components/web_package/test_support/signed_web_bundles/web_bundle_signer.h" |
| #include "components/web_package/web_bundle_builder.h" |
| #include "mojo/public/cpp/bindings/receiver_set.h" |
| #include "mojo/public/cpp/bindings/self_owned_receiver.h" |
| #include "testing/gtest/include/gtest/gtest.h" |
| |
| namespace web_package { |
| |
| namespace { |
| |
| constexpr char kPrimaryUrl[] = "https://test.example.com/"; |
| |
| std::string GetTestFileContents(const base::FilePath& path) { |
| base::FilePath test_data_dir; |
| base::PathService::Get(base::DIR_SRC_TEST_DATA_ROOT, &test_data_dir); |
| test_data_dir = test_data_dir.Append( |
| base::FilePath(FILE_PATH_LITERAL("components/test/data/web_package"))); |
| |
| std::string contents; |
| EXPECT_TRUE(base::ReadFileToString(test_data_dir.Append(path), &contents)); |
| return contents; |
| } |
| |
| class TestDataSource : public mojom::BundleDataSource { |
| public: |
| explicit TestDataSource(const base::FilePath& path, |
| const bool is_random_access_context = false) |
| : data_(GetTestFileContents(path)), |
| is_random_access_context_(is_random_access_context) {} |
| explicit TestDataSource(const std::vector<uint8_t>& data, |
| const bool is_random_access_context = false) |
| : data_(reinterpret_cast<const char*>(data.data()), data.size()), |
| is_random_access_context_(is_random_access_context) {} |
| |
| void Read(uint64_t offset, uint64_t length, ReadCallback callback) override { |
| if (offset >= data_.size()) { |
| std::move(callback).Run(std::nullopt); |
| return; |
| } |
| const uint8_t* start = |
| UNSAFE_TODO(reinterpret_cast<const uint8_t*>(data_.data()) + offset); |
| uint64_t available_length = std::min(length, data_.size() - offset); |
| std::move(callback).Run( |
| std::vector<uint8_t>(start, UNSAFE_TODO(start + available_length))); |
| } |
| |
| void Length(LengthCallback callback) override { |
| EXPECT_TRUE(is_random_access_context_); |
| std::move(callback).Run(data_.size()); |
| } |
| |
| void IsRandomAccessContext(IsRandomAccessContextCallback callback) override { |
| std::move(callback).Run(is_random_access_context_); |
| } |
| |
| std::string_view GetPayload(const mojom::BundleResponsePtr& response) { |
| return std::string_view(data_).substr(response->payload_offset, |
| response->payload_length); |
| } |
| |
| void AddReceiver(mojo::PendingReceiver<mojom::BundleDataSource> receiver) { |
| receivers_.Add(this, std::move(receiver)); |
| } |
| |
| void Close(CloseCallback callback) override { |
| is_closed_ = true; |
| std::move(callback).Run(); |
| } |
| |
| bool IsClosed() const { return is_closed_; } |
| |
| private: |
| std::string data_; |
| bool is_random_access_context_; |
| mojo::ReceiverSet<mojom::BundleDataSource> receivers_; |
| bool is_closed_ = false; |
| }; |
| |
| template <typename... T> |
| auto to_pair(std::tuple<T...>&& t) |
| -> decltype(std::make_pair(std::get<0>(t), std::get<1>(t))) { |
| return std::make_pair(std::move(std::get<0>(t)), std::move(std::get<1>(t))); |
| } |
| |
| using ParseSignedBundleIntegrityBlockResult = |
| base::expected<mojom::BundleIntegrityBlockPtr, |
| mojom::BundleIntegrityBlockParseErrorPtr>; |
| |
| ParseSignedBundleIntegrityBlockResult ParseSignedBundleIntegrityBlock( |
| TestDataSource* data_source, |
| const GURL& base_url = GURL()) { |
| mojo::PendingRemote<mojom::BundleDataSource> source_remote; |
| data_source->AddReceiver(source_remote.InitWithNewPipeAndPassReceiver()); |
| |
| WebBundleParser parser_impl(std::move(source_remote), base_url); |
| mojom::WebBundleParser& parser = parser_impl; |
| |
| base::test::TestFuture<mojom::BundleIntegrityBlockPtr, |
| mojom::BundleIntegrityBlockParseErrorPtr> |
| integrity_block_future; |
| parser.ParseIntegrityBlock(integrity_block_future.GetCallback()); |
| auto parse_result = to_pair(integrity_block_future.Take()); |
| EXPECT_TRUE((parse_result.first && !parse_result.second) || |
| (!parse_result.first && parse_result.second)); |
| |
| if (parse_result.first) { |
| return std::move(parse_result.first); |
| } else { |
| return base::unexpected(std::move(parse_result.second)); |
| } |
| } |
| |
| using ParseUnsignedBundleResult = |
| std::pair<mojom::BundleMetadataPtr, mojom::BundleMetadataParseErrorPtr>; |
| |
| ParseUnsignedBundleResult ParseUnsignedBundle( |
| TestDataSource* data_source, |
| const GURL& base_url = GURL(), |
| std::optional<uint64_t> offset = std::nullopt) { |
| mojo::PendingRemote<mojom::BundleDataSource> source_remote; |
| data_source->AddReceiver(source_remote.InitWithNewPipeAndPassReceiver()); |
| |
| WebBundleParser parser_impl(std::move(source_remote), base_url); |
| mojom::WebBundleParser& parser = parser_impl; |
| |
| base::test::TestFuture<mojom::BundleMetadataPtr, |
| mojom::BundleMetadataParseErrorPtr> |
| future; |
| parser.ParseMetadata(offset, future.GetCallback()); |
| ParseUnsignedBundleResult result = to_pair(future.Take()); |
| EXPECT_TRUE((result.first && !result.second) || |
| (!result.first && result.second)); |
| return result; |
| } |
| |
| void ExpectFormatError(ParseUnsignedBundleResult result) { |
| ASSERT_TRUE(result.second); |
| EXPECT_EQ(result.second->type, mojom::BundleParseErrorType::kFormatError); |
| } |
| |
| // Finds the response for |url|. |
| mojom::BundleResponseLocationPtr FindResponse( |
| const mojom::BundleMetadataPtr& metadata, |
| const GURL& url) { |
| const auto item = metadata->requests.find(url); |
| if (item == metadata->requests.end()) { |
| return nullptr; |
| } |
| |
| return item->second.Clone(); |
| } |
| |
| mojom::BundleResponsePtr ParseResponse( |
| TestDataSource* data_source, |
| const mojom::BundleResponseLocationPtr& location, |
| const GURL& base_url = GURL()) { |
| mojo::PendingRemote<mojom::BundleDataSource> source_remote; |
| data_source->AddReceiver(source_remote.InitWithNewPipeAndPassReceiver()); |
| |
| WebBundleParser parser_impl(std::move(source_remote), base_url); |
| mojom::WebBundleParser& parser = parser_impl; |
| |
| base::test::TestFuture<mojom::BundleResponsePtr, |
| mojom::BundleResponseParseErrorPtr> |
| future; |
| parser.ParseResponse(location->offset, location->length, |
| future.GetCallback()); |
| return std::get<0>(future.Take()); |
| } |
| |
| std::vector<uint8_t> CreateSmallBundle() { |
| web_package::WebBundleBuilder builder; |
| builder.AddExchange(kPrimaryUrl, |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| return builder.CreateBundle(); |
| } |
| |
| struct SignedWebBundleAndKeys { |
| std::vector<uint8_t> bundle; |
| std::vector<test::KeyPair> key_pairs; |
| }; |
| |
| SignedWebBundleAndKeys SignBundle( |
| const std::vector<uint8_t>& unsigned_bundle, |
| std::optional<test::WebBundleSigner::IntegrityBlockAttributes> |
| ib_attributes, |
| test::WebBundleSigner::ErrorsForTesting errors_for_testing = { |
| /*integrity_block_errors=*/{}, /*signatures_errors=*/{}}, |
| size_t num_signatures = 1) { |
| std::vector<test::KeyPair> key_pairs; |
| for (size_t i = 0; i < num_signatures; ++i) { |
| key_pairs.push_back(test::Ed25519KeyPair::CreateRandom()); |
| } |
| |
| return { |
| test::WebBundleSigner::SignBundle(unsigned_bundle, key_pairs, |
| ib_attributes, errors_for_testing), |
| key_pairs, |
| }; |
| } |
| |
| SignedWebBundleAndKeys SignBundle( |
| const std::vector<uint8_t>& unsigned_bundle, |
| test::WebBundleSigner::ErrorsForTesting errors_for_testing = { |
| /*integrity_block_errors=*/{}, /*signatures_errors=*/{}}, |
| size_t num_signatures = 1) { |
| return SignBundle(unsigned_bundle, /*ib_attributes=*/{}, errors_for_testing, |
| num_signatures); |
| } |
| |
| void CheckIfSignatureStackEntryIsValid( |
| const mojom::BundleIntegrityBlockSignatureStackEntryPtr& entry, |
| const Ed25519PublicKey& public_key) { |
| ASSERT_TRUE(entry->signature_info->is_ed25519()); |
| EXPECT_EQ(entry->signature_info->get_ed25519()->public_key, public_key); |
| |
| // The attributes should contain the public key. |
| EXPECT_NE( |
| std::ranges::search(entry->attributes_cbor, public_key.bytes()).begin(), |
| entry->attributes_cbor.end()); |
| } |
| |
| } // namespace |
| |
| using base::test::ErrorIs; |
| using testing::AllOf; |
| using testing::Eq; |
| using testing::Field; |
| using testing::HasSubstr; |
| using testing::Optional; |
| using testing::Pointee; |
| using testing::Property; |
| |
| class WebBundleParserTest : public testing::Test { |
| private: |
| base::test::TaskEnvironment task_environment_; |
| }; |
| |
| TEST_F(WebBundleParserTest, WrongMagic) { |
| WebBundleBuilder builder; |
| std::vector<uint8_t> bundle = builder.CreateBundle(); |
| bundle[3] ^= 1; |
| TestDataSource data_source(bundle); |
| |
| mojom::BundleMetadataParseErrorPtr error = |
| ParseUnsignedBundle(&data_source).second; |
| ASSERT_TRUE(error); |
| EXPECT_EQ(error->type, mojom::BundleParseErrorType::kFormatError); |
| } |
| |
| TEST_F(WebBundleParserTest, UnknownVersion) { |
| WebBundleBuilder builder; |
| std::vector<uint8_t> bundle = builder.CreateBundle(); |
| // Modify the version string from "b2\0\0" to "q2\0\0". |
| ASSERT_EQ(bundle[11], 'b'); |
| bundle[11] = 'q'; |
| TestDataSource data_source(bundle); |
| |
| mojom::BundleMetadataParseErrorPtr error = |
| ParseUnsignedBundle(&data_source).second; |
| ASSERT_TRUE(error); |
| EXPECT_EQ(error->type, mojom::BundleParseErrorType::kVersionError); |
| } |
| |
| TEST_F(WebBundleParserTest, SectionLengthsTooLarge) { |
| WebBundleBuilder builder; |
| std::string too_long_section_name(8192, 'x'); |
| builder.AddSection(too_long_section_name, cbor::Value(0)); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| ExpectFormatError(ParseUnsignedBundle(&data_source)); |
| } |
| |
| TEST_F(WebBundleParserTest, DuplicateSectionName) { |
| WebBundleBuilder builder; |
| builder.AddSection("foo", cbor::Value(0)); |
| builder.AddSection("foo", cbor::Value(0)); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| ExpectFormatError(ParseUnsignedBundle(&data_source)); |
| } |
| |
| TEST_F(WebBundleParserTest, InvalidRequestURL) { |
| WebBundleBuilder builder; |
| builder.AddExchange("", {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| ExpectFormatError(ParseUnsignedBundle(&data_source)); |
| } |
| |
| TEST_F(WebBundleParserTest, RequestURLIsNotUTF8) { |
| WebBundleBuilder builder(BundleVersion::kB2, |
| /*allow_invalid_utf8_strings_for_testing*/ true); |
| builder.AddExchange("https://test.example.com/\xcc", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| ExpectFormatError(ParseUnsignedBundle(&data_source)); |
| } |
| |
| // TODO(crbug.com/40629011): Revisit this once |
| // https://github.com/WICG/webpackage/issues/468 is resolved. |
| TEST_F(WebBundleParserTest, RequestURLHasNonStandardScheme) { |
| WebBundleBuilder builder; |
| builder.AddExchange("foo://bar", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| ASSERT_TRUE(ParseUnsignedBundle(&data_source).first); |
| } |
| |
| TEST_F(WebBundleParserTest, RequestURLHasIsolatedAppScheme) { |
| WebBundleBuilder builder; |
| builder.AddExchange("isolated-app://foo", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| ASSERT_TRUE(ParseUnsignedBundle(&data_source).first); |
| } |
| |
| TEST_F(WebBundleParserTest, RequestURLHasCredentials) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://user:passwd@test.example.com/", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| ExpectFormatError(ParseUnsignedBundle(&data_source)); |
| } |
| |
| TEST_F(WebBundleParserTest, RequestURLHasFragment) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://test.example.com/#fragment", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| ExpectFormatError(ParseUnsignedBundle(&data_source)); |
| } |
| |
| TEST_F(WebBundleParserTest, RequestURLIsValidUuidInPackage) { |
| const char uuid_in_package[] = |
| "uuid-in-package:f81d4fae-7dec-11d0-a765-00a0c91e6bf6"; |
| WebBundleBuilder builder; |
| builder.AddExchange(uuid_in_package, |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| ASSERT_EQ(metadata->requests.size(), 1u); |
| auto location = FindResponse(metadata, GURL(uuid_in_package)); |
| ASSERT_TRUE(location); |
| } |
| |
| TEST_F(WebBundleParserTest, NoStatusInResponseHeaders) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://test.example.com/", |
| {{"content-type", "text/plain"}}, |
| "payload"); // ":status" is missing. |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| ASSERT_FALSE(ParseResponse(&data_source, location)); |
| } |
| |
| TEST_F(WebBundleParserTest, InvalidResponseStatus) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://test.example.com/", |
| {{":status", "0200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| ASSERT_FALSE(ParseResponse(&data_source, location)); |
| } |
| |
| TEST_F(WebBundleParserTest, ExtraPseudoInResponseHeaders) { |
| WebBundleBuilder builder; |
| builder.AddExchange( |
| "https://test.example.com/", |
| {{":status", "200"}, {":foo", ""}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| ASSERT_FALSE(ParseResponse(&data_source, location)); |
| } |
| |
| TEST_F(WebBundleParserTest, UpperCaseCharacterInHeaderName) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://test.example.com/", |
| {{":status", "200"}, {"Content-Type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| ASSERT_FALSE(ParseResponse(&data_source, location)); |
| } |
| |
| TEST_F(WebBundleParserTest, InvalidHeaderValue) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://test.example.com/", |
| {{":status", "200"}, {"content-type", "\n"}}, "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| ASSERT_FALSE(ParseResponse(&data_source, location)); |
| } |
| |
| TEST_F(WebBundleParserTest, NoContentTypeWithNonEmptyContent) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://test.example.com/", {{":status", "200"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| ASSERT_FALSE(ParseResponse(&data_source, location)); |
| } |
| |
| TEST_F(WebBundleParserTest, NoContentTypeWithEmptyContent) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://test.example.com/", {{":status", "301"}}, ""); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| ASSERT_TRUE(ParseResponse(&data_source, location)); |
| } |
| |
| TEST_F(WebBundleParserTest, AllKnownSectionInCritical) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://test.example.com/", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| cbor::Value::ArrayValue critical_section; |
| critical_section.emplace_back("index"); |
| critical_section.emplace_back("critical"); |
| critical_section.emplace_back("responses"); |
| builder.AddSection("critical", cbor::Value(critical_section)); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| } |
| |
| TEST_F(WebBundleParserTest, UnknownSectionInCritical) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://test.example.com/", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| cbor::Value::ArrayValue critical_section; |
| critical_section.emplace_back("unknown_section_name"); |
| builder.AddSection("critical", cbor::Value(critical_section)); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| ExpectFormatError(ParseUnsignedBundle(&data_source)); |
| } |
| |
| TEST_F(WebBundleParserTest, ParseGoldenFile) { |
| TestDataSource data_source(base::FilePath(FILE_PATH_LITERAL("hello_b2.wbn"))); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| ASSERT_EQ(metadata->requests.size(), 4u); |
| EXPECT_EQ(metadata->primary_url, "https://test.example.org/"); |
| |
| std::map<std::string, mojom::BundleResponsePtr> responses; |
| for (const auto& item : metadata->requests) { |
| auto location = FindResponse(metadata, item.first); |
| ASSERT_TRUE(location); |
| auto resp = ParseResponse(&data_source, location); |
| ASSERT_TRUE(resp); |
| responses[item.first.spec()] = std::move(resp); |
| } |
| |
| ASSERT_TRUE(responses["https://test.example.org/"]); |
| EXPECT_EQ(responses["https://test.example.org/"]->response_code, 200); |
| EXPECT_EQ( |
| responses["https://test.example.org/"]->response_headers["content-type"], |
| "text/html; charset=utf-8"); |
| EXPECT_EQ(data_source.GetPayload(responses["https://test.example.org/"]), |
| GetTestFileContents( |
| base::FilePath(FILE_PATH_LITERAL("hello/index.html")))); |
| |
| EXPECT_TRUE(responses["https://test.example.org/index.html"]); |
| EXPECT_TRUE(responses["https://test.example.org/manifest.webmanifest"]); |
| EXPECT_TRUE(responses["https://test.example.org/script.js"]); |
| } |
| |
| TEST_F(WebBundleParserTest, SingleEntry) { |
| WebBundleBuilder builder; |
| builder.AddPrimaryURL(kPrimaryUrl); |
| builder.AddExchange("https://test.example.com/", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| ASSERT_EQ(metadata->version, mojom::BundleFormatVersion::kB2); |
| ASSERT_EQ(metadata->requests.size(), 1u); |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| auto response = ParseResponse(&data_source, location); |
| ASSERT_TRUE(response); |
| EXPECT_EQ(response->response_code, 200); |
| EXPECT_EQ(response->response_headers.size(), 1u); |
| EXPECT_EQ(response->response_headers["content-type"], "text/plain"); |
| EXPECT_EQ(data_source.GetPayload(response), "payload"); |
| EXPECT_EQ(metadata->primary_url, kPrimaryUrl); |
| } |
| |
| TEST_F(WebBundleParserTest, NoPrimaryUrlSingleEntry) { |
| WebBundleBuilder builder; |
| builder.AddExchange("https://test.example.com/", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| ASSERT_EQ(metadata->requests.size(), 1u); |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| auto response = ParseResponse(&data_source, location); |
| ASSERT_TRUE(response); |
| EXPECT_EQ(response->response_code, 200); |
| EXPECT_EQ(response->response_headers.size(), 1u); |
| EXPECT_EQ(response->response_headers["content-type"], "text/plain"); |
| EXPECT_EQ(data_source.GetPayload(response), "payload"); |
| EXPECT_FALSE(metadata->primary_url.has_value()); |
| } |
| |
| TEST_F(WebBundleParserTest, RelativeURL) { |
| WebBundleBuilder builder; |
| builder.AddPrimaryURL("path/to/primary_url"); |
| builder.AddExchange("path/to/file.txt", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| const GURL base_url("https://test.example.com/dir/test.wbn"); |
| mojom::BundleMetadataPtr metadata = |
| ParseUnsignedBundle(&data_source, base_url).first; |
| ASSERT_TRUE(metadata); |
| EXPECT_EQ(metadata->primary_url, |
| "https://test.example.com/dir/path/to/primary_url"); |
| ASSERT_TRUE(metadata); |
| ASSERT_EQ(metadata->requests.size(), 1u); |
| auto location = FindResponse( |
| metadata, GURL("https://test.example.com/dir/path/to/file.txt")); |
| ASSERT_TRUE(location); |
| auto response = ParseResponse(&data_source, location, base_url); |
| ASSERT_TRUE(response); |
| EXPECT_EQ(response->response_code, 200); |
| EXPECT_EQ(response->response_headers.size(), 1u); |
| EXPECT_EQ(response->response_headers["content-type"], "text/plain"); |
| EXPECT_EQ(data_source.GetPayload(response), "payload"); |
| } |
| |
| TEST_F(WebBundleParserTest, RandomAccessContextWithAutomaticOffset) { |
| std::vector<uint8_t> bundle = CreateSmallBundle(); |
| TestDataSource data_source(bundle, /*is_random_access_context=*/true); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| auto response = ParseResponse(&data_source, location); |
| ASSERT_TRUE(response); |
| EXPECT_EQ(response->response_code, 200); |
| EXPECT_EQ(response->response_headers.size(), 1u); |
| EXPECT_EQ(response->response_headers["content-type"], "text/plain"); |
| EXPECT_EQ(data_source.GetPayload(response), "payload"); |
| } |
| |
| TEST_F(WebBundleParserTest, RandomAccessContextWithFixedCorrectOffset) { |
| std::vector<uint8_t> bundle = CreateSmallBundle(); |
| TestDataSource data_source(bundle, /*is_random_access_context=*/true); |
| |
| mojom::BundleMetadataPtr metadata = |
| ParseUnsignedBundle(&data_source, GURL(), 0).first; |
| ASSERT_TRUE(metadata); |
| |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| auto response = ParseResponse(&data_source, location); |
| ASSERT_TRUE(response); |
| EXPECT_EQ(response->response_code, 200); |
| EXPECT_EQ(response->response_headers.size(), 1u); |
| EXPECT_EQ(response->response_headers["content-type"], "text/plain"); |
| EXPECT_EQ(data_source.GetPayload(response), "payload"); |
| } |
| |
| TEST_F(WebBundleParserTest, RandomAccessContextWithFixedIncorrectOffset) { |
| std::vector<uint8_t> bundle = CreateSmallBundle(); |
| TestDataSource data_source(bundle, /*is_random_access_context=*/true); |
| |
| mojom::BundleMetadataParseErrorPtr error = |
| ParseUnsignedBundle(&data_source, GURL(), 1).second; |
| ASSERT_TRUE(error); |
| } |
| |
| TEST_F(WebBundleParserTest, |
| RandomAccessContextPrependedDataWithAutomaticOffset) { |
| std::vector<uint8_t> bundle = CreateSmallBundle(); |
| bundle.insert(bundle.begin(), |
| {'o', 't', 'h', 'e', 'r', ' ', 'd', 'a', 't', 'a'}); |
| TestDataSource data_source(bundle, /*is_random_access_context=*/true); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| auto response = ParseResponse(&data_source, location); |
| ASSERT_TRUE(response); |
| EXPECT_EQ(response->response_code, 200); |
| EXPECT_EQ(response->response_headers.size(), 1u); |
| EXPECT_EQ(response->response_headers["content-type"], "text/plain"); |
| EXPECT_EQ(data_source.GetPayload(response), "payload"); |
| } |
| |
| TEST_F(WebBundleParserTest, |
| RandomAccessContextPrependedDataWithFixedCorrectOffset) { |
| std::vector<uint8_t> bundle = CreateSmallBundle(); |
| bundle.insert(bundle.begin(), |
| {'o', 't', 'h', 'e', 'r', ' ', 'd', 'a', 't', 'a'}); |
| TestDataSource data_source(bundle, /*is_random_access_context=*/true); |
| |
| mojom::BundleMetadataPtr metadata = |
| ParseUnsignedBundle(&data_source, GURL(), 10).first; |
| ASSERT_TRUE(metadata); |
| |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| auto response = ParseResponse(&data_source, location); |
| ASSERT_TRUE(response); |
| EXPECT_EQ(response->response_code, 200); |
| EXPECT_EQ(response->response_headers.size(), 1u); |
| EXPECT_EQ(response->response_headers["content-type"], "text/plain"); |
| EXPECT_EQ(data_source.GetPayload(response), "payload"); |
| } |
| |
| TEST_F(WebBundleParserTest, |
| RandomAccessContextPrependedDataWithFixedIncorrectOffset) { |
| std::vector<uint8_t> bundle = CreateSmallBundle(); |
| bundle.insert(bundle.begin(), |
| {'o', 't', 'h', 'e', 'r', ' ', 'd', 'a', 't', 'a'}); |
| TestDataSource data_source(bundle, /*is_random_access_context=*/true); |
| |
| mojom::BundleMetadataParseErrorPtr error = |
| ParseUnsignedBundle(&data_source, GURL(), 3).second; |
| ASSERT_TRUE(error); |
| } |
| |
| TEST_F(WebBundleParserTest, RandomAccessContextLengthSmallerThanWebBundle) { |
| std::vector<uint8_t> bundle = CreateSmallBundle(); |
| std::vector<uint8_t> invalid_length = {0, 0, 0, 0, 0, 0, 0, 10}; |
| std::ranges::copy(invalid_length, bundle.end() - 8); |
| TestDataSource data_source(bundle, /*is_random_access_context=*/true); |
| |
| ExpectFormatError(ParseUnsignedBundle(&data_source)); |
| } |
| |
| TEST_F(WebBundleParserTest, RandomAccessContextFileSmallerThanLengthField) { |
| std::vector<uint8_t> bundle = {1, 2, 3, 4}; |
| TestDataSource data_source(bundle, /*is_random_access_context=*/true); |
| |
| ExpectFormatError(ParseUnsignedBundle(&data_source)); |
| } |
| |
| TEST_F(WebBundleParserTest, RandomAccessContextLengthBiggerThanFile) { |
| std::vector<uint8_t> bundle = CreateSmallBundle(); |
| std::vector<uint8_t> invalid_length = {0xff, 0, 0, 0, 0, 0, 0, 0}; |
| std::ranges::copy(invalid_length, bundle.end() - 8); |
| TestDataSource data_source(bundle, /*is_random_access_context=*/true); |
| |
| ExpectFormatError(ParseUnsignedBundle(&data_source)); |
| } |
| |
| // TODO(crbug.com/40630324): Add a test case that loads a wbn file with |
| // variants, once gen-bundle supports variants. |
| |
| // This test verifies that even if a bundle is signed, it is still readable as |
| // an unsigned bundle in random-access contexts, since the `length` field of the |
| // web bundle can be used to find the start of the unsigned bundle. |
| TEST_F(WebBundleParserTest, SignedBundleMetadataOnlyInRandomAccessContexts) { |
| auto bundle_and_keys = SignBundle(CreateSmallBundle()); |
| TestDataSource data_source(bundle_and_keys.bundle, true); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| EXPECT_TRUE(metadata); |
| } |
| |
| // This test verifies that when a bundle is signed, it can not be read as an |
| // unsigned bundle in non-random-access contexts, since the `length` field of |
| // the web bundle can't be used then. |
| TEST_F(WebBundleParserTest, SignedBundleMetadataOnlyInNonRandomAccessContexts) { |
| auto bundle_and_keys = SignBundle(CreateSmallBundle()); |
| TestDataSource data_source(bundle_and_keys.bundle, false); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| EXPECT_FALSE(metadata); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleIntegrityBlockIsParsedCorrectly) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = SignBundle(unsigned_bundle); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| ASSERT_OK_AND_ASSIGN(auto integrity_block, |
| ParseSignedBundleIntegrityBlock(&data_source)); |
| |
| // The size of the integrity block should be exactly equal to the size |
| // difference between a signed and an unsigned bundle. |
| EXPECT_EQ(integrity_block->size, |
| bundle_and_keys.bundle.size() - unsigned_bundle.size()); |
| |
| // There should be exactly one signature stack entry, corresponding to the |
| // public key that was used to sign the web bundle. |
| EXPECT_EQ(integrity_block->signature_stack.size(), 1ul); |
| auto& entry = integrity_block->signature_stack[0]; |
| EXPECT_NO_FATAL_FAILURE(CheckIfSignatureStackEntryIsValid( |
| entry, |
| std::get<test::Ed25519KeyPair>(bundle_and_keys.key_pairs[0]).public_key)); |
| } |
| |
| TEST_F(WebBundleParserTest, |
| SignedBundleSignatureStackWithMultipleEntries_AllValid) { |
| unsigned long num_signatures = base::RandInt(2, 15); |
| |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, /*errors_for_testing=*/ |
| {/*integrity_block_errors=*/{}, /*signatures_errors=*/{}}, |
| num_signatures); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| ASSERT_OK_AND_ASSIGN(auto integrity_block, |
| ParseSignedBundleIntegrityBlock(&data_source)); |
| |
| // The size of the integrity block should be exactly equal to the size |
| // difference between a signed and an unsigned bundle. |
| EXPECT_EQ(integrity_block->size, |
| bundle_and_keys.bundle.size() - unsigned_bundle.size()); |
| |
| // The signature stack should contain the expected number of signatures, and |
| // each entry should correspond to the public key that was used to sign the |
| // web bundle. |
| EXPECT_EQ(integrity_block->signature_stack.size(), num_signatures); |
| |
| for (unsigned long i = 0; i < num_signatures; ++i) { |
| EXPECT_NO_FATAL_FAILURE(CheckIfSignatureStackEntryIsValid( |
| integrity_block->signature_stack[i], |
| std::get<test::Ed25519KeyPair>(bundle_and_keys.key_pairs[i]) |
| .public_key)); |
| } |
| } |
| |
| TEST_F(WebBundleParserTest, |
| SignedBundleSignatureStackWithMultipleEntries_SomeInvalid) { |
| std::vector<test::WebBundleSigner::IntegritySignatureErrorsForTesting> |
| signatures_errors = { |
| {}, |
| {}, |
| {test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kWrongSignatureStackEntryAttributeNameLength}, |
| {test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kWrongSignatureStackEntryAttributeNameLength}, |
| {test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kWrongSignatureStackEntryAttributeNameLength}, |
| {}, |
| {}}; |
| auto total_signatures = signatures_errors.size(); |
| |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = SignBundle( |
| unsigned_bundle, {/*integrity_block_errors=*/{}, signatures_errors}, |
| total_signatures); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| ASSERT_OK_AND_ASSIGN(auto integrity_block, |
| ParseSignedBundleIntegrityBlock(&data_source)); |
| |
| // The size of the integrity block should be exactly equal to the size |
| // difference between a signed and an unsigned bundle. |
| EXPECT_EQ(integrity_block->size, |
| bundle_and_keys.bundle.size() - unsigned_bundle.size()); |
| |
| // The signature stack should contain all the signatures, with the invalid |
| // signatures of type unknown and each valid entry should correspond to the |
| // public key that was used to sign the web bundle. |
| EXPECT_EQ(integrity_block->signature_stack.size(), total_signatures); |
| |
| for (size_t index = 0; index < total_signatures; ++index) { |
| if (signatures_errors[index].empty()) { |
| auto* key_pair = |
| std::get_if<test::Ed25519KeyPair>(&bundle_and_keys.key_pairs[index]); |
| EXPECT_NO_FATAL_FAILURE(CheckIfSignatureStackEntryIsValid( |
| integrity_block->signature_stack[index], key_pair->public_key)); |
| } else { |
| EXPECT_TRUE(integrity_block->signature_stack[index] |
| ->signature_info->is_unknown()); |
| } |
| } |
| } |
| |
| TEST_F(WebBundleParserTest, |
| SignedBundleSignatureStackWithMultipleEntries_FirstInvalid) { |
| size_t total_signatures = 3; |
| std::vector<test::WebBundleSigner::IntegritySignatureErrorsForTesting> |
| signatures_errors(total_signatures); |
| signatures_errors[0] = { |
| test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kWrongSignatureStackEntryAttributeNameLength}; |
| |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = SignBundle( |
| unsigned_bundle, {/*integrity_block_errors=*/{}, signatures_errors}, |
| total_signatures); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee(AllOf( |
| Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("Unknown cipher type of the first signature.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleWrongMagic) { |
| WebBundleBuilder builder; |
| std::vector<uint8_t> unsigned_bundle = builder.CreateBundle(); |
| auto bundle_and_keys = SignBundle(unsigned_bundle); |
| bundle_and_keys.bundle[3] ^= 1; |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee( |
| AllOf(Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("Unexpected magic bytes.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleUnknownVersion) { |
| WebBundleBuilder builder; |
| std::vector<uint8_t> unsigned_bundle = builder.CreateBundle(); |
| auto bundle_and_keys = SignBundle(unsigned_bundle); |
| // Modify the version string from "1b\0\0" to "1q\0\0". |
| ASSERT_EQ(bundle_and_keys.bundle[12], 'b'); |
| bundle_and_keys.bundle[12] = 'q'; |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT( |
| ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee(Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kVersionError))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleEmptySignatureStack) { |
| std::vector<uint8_t> signed_bundle = test::WebBundleSigner::SignBundle( |
| WebBundleBuilder().CreateBundle(), /*key_pairs=*/{}, /*ib_attributes=*/ |
| {{.web_bundle_id = |
| "amoiebz32b7o24tilu257xne2yf3nkblkploanxzm7ebeglseqpfeaacai"}}, |
| {/*integrity_block_errors=*/{ |
| test::WebBundleSigner::IntegrityBlockErrorForTesting:: |
| kEmptySignatureList}, |
| /*signatures_errors=*/{}}); |
| TestDataSource data_source(signed_bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee( |
| AllOf(Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("The signature stack must contain at least " |
| "one signature.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleNoBundleId) { |
| WebBundleBuilder builder; |
| std::vector<uint8_t> unsigned_bundle = builder.CreateBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{ |
| test::WebBundleSigner::IntegrityBlockErrorForTesting:: |
| kNoSignedWebBundleId}, |
| /*signatures_errors=*/{}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee( |
| AllOf(Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("`webBundleId` field in integrity block " |
| "attributes is missing or malformed.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleNoAttributes) { |
| WebBundleBuilder builder; |
| std::vector<uint8_t> unsigned_bundle = builder.CreateBundle(); |
| auto bundle_and_keys = SignBundle( |
| unsigned_bundle, |
| {/*integrity_block_errors=*/{ |
| test::WebBundleSigner::IntegrityBlockErrorForTesting::kNoAttributes}, |
| /*signatures_errors=*/{}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT( |
| ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee(AllOf( |
| Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("Integrity block array of length 3 - should be 4.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleWrongSignatureLength) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kInvalidSignatureLength}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee( |
| AllOf(Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("The signature has the wrong length. Expected " |
| "64, but got 65 bytes.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleWrongSignatureStackEntryLength) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kAdditionalSignatureStackEntryElement}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee( |
| AllOf(Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("Each signature stack entry must contain " |
| "exactly two elements.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleWithMultipleAttributes) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kAdditionalSignatureStackEntryAttributes}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| ASSERT_OK_AND_ASSIGN(auto integrity_block, |
| ParseSignedBundleIntegrityBlock(&data_source)); |
| |
| // The size of the integrity block should be exactly equal to the size |
| // difference between a signed and an unsigned bundle. |
| EXPECT_EQ(integrity_block->size, |
| bundle_and_keys.bundle.size() - unsigned_bundle.size()); |
| |
| // There should be exactly one signature stack entry, corresponding to the |
| // public key that was used to sign the web bundle. |
| EXPECT_EQ(integrity_block->signature_stack.size(), 1ul); |
| auto& entry = integrity_block->signature_stack[0]; |
| EXPECT_NO_FATAL_FAILURE(CheckIfSignatureStackEntryIsValid( |
| entry, |
| std::get<test::Ed25519KeyPair>(bundle_and_keys.key_pairs[0]).public_key)); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleV2) { |
| static constexpr std::string_view kWebBundleId = |
| "aerugqztij5biqquuk3mfwpsaibuegaqcitgfchwuosuofdjabzqaaic"; |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = SignBundle( |
| unsigned_bundle, {{.web_bundle_id = std::string(kWebBundleId)}}, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kAdditionalSignatureStackEntryAttributes}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| ASSERT_OK_AND_ASSIGN(auto integrity_block, |
| ParseSignedBundleIntegrityBlock(&data_source)); |
| |
| // The size of the integrity block should be exactly equal to the size |
| // difference between a signed and an unsigned bundle. |
| EXPECT_EQ(integrity_block->size, |
| bundle_and_keys.bundle.size() - unsigned_bundle.size()); |
| |
| EXPECT_THAT( |
| integrity_block->attributes, |
| Property(&IntegrityBlockAttributes::web_bundle_id, Eq(kWebBundleId))); |
| |
| // There should be exactly one signature stack entry, corresponding to the |
| // public key that was used to sign the web bundle. |
| EXPECT_EQ(integrity_block->signature_stack.size(), 1ul); |
| auto& entry = integrity_block->signature_stack[0]; |
| EXPECT_NO_FATAL_FAILURE(CheckIfSignatureStackEntryIsValid( |
| entry, |
| std::get<test::Ed25519KeyPair>(bundle_and_keys.key_pairs[0]).public_key)); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleWithMultiplePublicKeyAttributes) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kMultipleValidPublicKeyAttributes}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee(AllOf( |
| Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("Unknown cipher type of the first signature.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleUnsupportedSignatureAttributeMap) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kSignatureStackEntryUnsupportedMapAttribute}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT( |
| ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee(AllOf( |
| Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| HasSubstr("nested attributes are currently not supported")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleUnsupportedSignatureAttributeArray) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kSignatureStackEntryUnsupportedArrayAttribute}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT( |
| ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee(AllOf( |
| Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| HasSubstr("nested attributes are currently not supported")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleNoPublicKeyAttribute) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kNoPublicKeySignatureStackEntryAttribute}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee(AllOf( |
| Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("Unknown cipher type of the first signature.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleWrongPublicKeyAttributeName) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kWrongSignatureStackEntryAttributeName}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee(AllOf( |
| Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("Unknown cipher type of the first signature.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleWrongPublicKeyAttributeLength) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kWrongSignatureStackEntryAttributeNameLength}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee(AllOf( |
| Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("Unknown cipher type of the first signature.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, SignedBundleWrongPublicKeyLength) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| auto bundle_and_keys = |
| SignBundle(unsigned_bundle, |
| {/*integrity_block_errors=*/{}, |
| {{test::WebBundleSigner::IntegritySignatureErrorForTesting:: |
| kInvalidPublicKeyLength}}}); |
| TestDataSource data_source(bundle_and_keys.bundle); |
| |
| EXPECT_THAT(ParseSignedBundleIntegrityBlock(&data_source), |
| ErrorIs(Pointee( |
| AllOf(Field(&mojom::BundleIntegrityBlockParseError::type, |
| Eq(mojom::BundleParseErrorType::kFormatError)), |
| Field(&mojom::BundleIntegrityBlockParseError::message, |
| Eq("The Ed25519 public key does not have the " |
| "correct length. Expected " |
| "32 bytes, but received 33 bytes.")))))); |
| } |
| |
| TEST_F(WebBundleParserTest, DisconnectWhileParsingMetadata) { |
| base::test::TestFuture<mojom::BundleMetadataPtr, |
| mojom::BundleMetadataParseErrorPtr> |
| future; |
| { |
| WebBundleBuilder builder; |
| builder.AddPrimaryURL(kPrimaryUrl); |
| builder.AddExchange("https://test.example.com/", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojo::PendingRemote<mojom::BundleDataSource> source_remote; |
| data_source.AddReceiver(source_remote.InitWithNewPipeAndPassReceiver()); |
| |
| WebBundleParser parser_impl(std::move(source_remote), GURL()); |
| mojom::WebBundleParser& parser = parser_impl; |
| |
| parser.ParseMetadata(/*offset=*/std::nullopt, future.GetCallback()); |
| // |data_source| and |parser_impl| are deleted here. |
| } |
| |
| auto error = std::get<1>(future.Take()); |
| ASSERT_TRUE(error); |
| EXPECT_EQ(error->type, mojom::BundleParseErrorType::kParserInternalError); |
| EXPECT_EQ(error->message, "Data source disconnected."); |
| } |
| |
| TEST_F(WebBundleParserTest, DisconnectWhileParsingResponse) { |
| base::test::TestFuture<mojom::BundleResponsePtr, |
| mojom::BundleResponseParseErrorPtr> |
| future; |
| { |
| WebBundleBuilder builder; |
| builder.AddPrimaryURL(kPrimaryUrl); |
| builder.AddExchange("https://test.example.com/", |
| {{":status", "200"}, {"content-type", "text/plain"}}, |
| "payload"); |
| TestDataSource data_source(builder.CreateBundle()); |
| |
| mojom::BundleMetadataPtr metadata = ParseUnsignedBundle(&data_source).first; |
| ASSERT_TRUE(metadata); |
| auto location = FindResponse(metadata, GURL("https://test.example.com/")); |
| ASSERT_TRUE(location); |
| |
| mojo::PendingRemote<mojom::BundleDataSource> source_remote; |
| data_source.AddReceiver(source_remote.InitWithNewPipeAndPassReceiver()); |
| |
| WebBundleParser parser_impl(std::move(source_remote), GURL()); |
| mojom::WebBundleParser& parser = parser_impl; |
| |
| parser.ParseResponse(location->offset, location->length, |
| future.GetCallback()); |
| // |data_source| and |parser_impl| are deleted here. |
| } |
| |
| auto error = std::get<1>(future.Take()); |
| ASSERT_TRUE(error); |
| EXPECT_EQ(error->type, mojom::BundleParseErrorType::kParserInternalError); |
| EXPECT_EQ(error->message, "Data source disconnected."); |
| } |
| |
| // This data source implementation never run result callback |
| // making the calls to it permanently pending. |
| class BlockingDataSource : public mojom::BundleDataSource { |
| public: |
| void Read(uint64_t offset, uint64_t length, ReadCallback callback) override {} |
| void Length(LengthCallback callback) override {} |
| void IsRandomAccessContext(IsRandomAccessContextCallback callback) override {} |
| void Close(CloseCallback callback) override {} |
| }; |
| |
| TEST_F(WebBundleParserTest, DestructorWhileParsing) { |
| base::test::TestFuture<mojom::BundleResponsePtr, |
| mojom::BundleResponseParseErrorPtr> |
| response_future; |
| base::test::TestFuture<mojom::BundleMetadataPtr, |
| mojom::BundleMetadataParseErrorPtr> |
| metadata_future; |
| base::test::TestFuture<mojom::BundleIntegrityBlockPtr, |
| mojom::BundleIntegrityBlockParseErrorPtr> |
| integrity_block_future; |
| |
| mojo::PendingRemote<mojom::BundleDataSource> source_remote; |
| mojo::MakeSelfOwnedReceiver(std::make_unique<BlockingDataSource>(), |
| source_remote.InitWithNewPipeAndPassReceiver()); |
| { |
| WebBundleParser parser_impl(std::move(source_remote), GURL()); |
| mojom::WebBundleParser& parser = parser_impl; |
| |
| parser.ParseResponse(/*response_offset=*/100, /*response_length=*/1234, |
| response_future.GetCallback()); |
| parser.ParseMetadata(/*offset=*/std::nullopt, |
| metadata_future.GetCallback()); |
| parser.ParseIntegrityBlock(integrity_block_future.GetCallback()); |
| //|parser_impl| are deleted here. |
| } |
| |
| { |
| auto error_response = std::get<1>(response_future.Take()); |
| ASSERT_TRUE(error_response); |
| EXPECT_EQ(error_response->type, |
| mojom::BundleParseErrorType::kParserInternalError); |
| EXPECT_EQ(error_response->message, "Data source disconnected."); |
| } |
| |
| { |
| auto error_metadata = std::get<1>(metadata_future.Take()); |
| ASSERT_TRUE(error_metadata); |
| EXPECT_EQ(error_metadata->type, |
| mojom::BundleParseErrorType::kParserInternalError); |
| EXPECT_EQ(error_metadata->message, "Data source disconnected."); |
| } |
| |
| { |
| auto error_integrity_block = std::get<1>(integrity_block_future.Take()); |
| ASSERT_TRUE(error_integrity_block); |
| EXPECT_EQ(error_integrity_block->type, |
| mojom::BundleParseErrorType::kParserInternalError); |
| EXPECT_EQ(error_integrity_block->message, "Data source disconnected."); |
| } |
| } |
| |
| TEST_F(WebBundleParserTest, Close) { |
| auto unsigned_bundle = CreateSmallBundle(); |
| TestDataSource data_source(unsigned_bundle); |
| EXPECT_FALSE(data_source.IsClosed()); |
| |
| mojo::PendingRemote<mojom::BundleDataSource> source_remote; |
| data_source.AddReceiver(source_remote.InitWithNewPipeAndPassReceiver()); |
| |
| WebBundleParser parser_impl(std::move(source_remote), GURL()); |
| mojom::WebBundleParser& parser = parser_impl; |
| |
| base::test::TestFuture<void> future; |
| parser.Close(future.GetCallback()); |
| future.Get(); |
| EXPECT_TRUE(data_source.IsClosed()); |
| } |
| |
| } // namespace web_package |