Commit 240c8a0c authored by Antonio Sartori's avatar Antonio Sartori Committed by Chromium LUCI CQ

CSP: Replace blink::CSPSource with mojo type

In the Blink Content Security Policy code, we switch from using the
internal blink type blink::CSPSource to using the mojo type
network::mojom::blink::CSPSource for handling CSP source expressions.

This is part of a project to harmonize the CSP code in Blink and in
services/network, and will make it easier to synchronize Content
Security Policies between the two.

Change-Id: If7769b321934ee73cf1aa0faa6d8b371360684a7
Bug: 1021462,1149272
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2412339Reviewed-by: default avatarMike West <mkwst@chromium.org>
Reviewed-by: default avatarArthur Sonzogni <arthursonzogni@chromium.org>
Commit-Queue: Antonio Sartori <antoniosartori@chromium.org>
Cr-Commit-Position: refs/heads/master@{#834599}
parent a66dbdcf
...@@ -178,19 +178,18 @@ void ContentSecurityPolicy::BindToDelegate( ...@@ -178,19 +178,18 @@ void ContentSecurityPolicy::BindToDelegate(
void ContentSecurityPolicy::SetupSelf(const SecurityOrigin& security_origin) { void ContentSecurityPolicy::SetupSelf(const SecurityOrigin& security_origin) {
// Ensure that 'self' processes correctly. // Ensure that 'self' processes correctly.
self_protocol_ = security_origin.Protocol(); self_protocol_ = security_origin.Protocol();
self_source_ = MakeGarbageCollected<CSPSource>( self_source_ = network::mojom::blink::CSPSource::New(
this, self_protocol_, security_origin.Host(), self_protocol_, security_origin.Host(),
security_origin.Port() == DefaultPortForProtocol(self_protocol_) security_origin.Port() == DefaultPortForProtocol(self_protocol_)
? CSPSource::kPortUnspecified ? url::PORT_UNSPECIFIED
: security_origin.Port(), : security_origin.Port(),
String(), CSPSource::kNoWildcard, CSPSource::kNoWildcard); "", /*is_host_wildcard=*/false, /*is_port_wildcard=*/false);
} }
void ContentSecurityPolicy::SetupSelf(const ContentSecurityPolicy& other) { void ContentSecurityPolicy::SetupSelf(const ContentSecurityPolicy& other) {
self_protocol_ = other.self_protocol_; self_protocol_ = other.self_protocol_;
if (other.self_source_) { if (other.self_source_) {
self_source_ = self_source_ = other.self_source_.Clone();
MakeGarbageCollected<CSPSource>(this, *(other.self_source_.Get()));
} }
} }
...@@ -294,15 +293,14 @@ void ContentSecurityPolicy::Trace(Visitor* visitor) const { ...@@ -294,15 +293,14 @@ void ContentSecurityPolicy::Trace(Visitor* visitor) const {
visitor->Trace(delegate_); visitor->Trace(delegate_);
visitor->Trace(policies_); visitor->Trace(policies_);
visitor->Trace(console_messages_); visitor->Trace(console_messages_);
visitor->Trace(self_source_);
} }
void ContentSecurityPolicy::CopyStateFrom(const ContentSecurityPolicy* other) { void ContentSecurityPolicy::CopyStateFrom(const ContentSecurityPolicy* other) {
DCHECK(policies_.IsEmpty()); DCHECK(policies_.IsEmpty());
SetupSelf(*other);
for (const auto& policy : other->policies_) for (const auto& policy : other->policies_)
AddAndReportPolicyFromHeaderValue(policy->Header(), policy->HeaderType(), AddAndReportPolicyFromHeaderValue(policy->Header(), policy->HeaderType(),
policy->HeaderSource()); policy->HeaderSource());
SetupSelf(*other);
} }
void ContentSecurityPolicy::CopyPluginTypesFrom( void ContentSecurityPolicy::CopyPluginTypesFrom(
...@@ -435,9 +433,9 @@ void ContentSecurityPolicy::SetOverrideURLForSelf(const KURL& url) { ...@@ -435,9 +433,9 @@ void ContentSecurityPolicy::SetOverrideURLForSelf(const KURL& url) {
// to an execution context. // to an execution context.
scoped_refptr<const SecurityOrigin> origin = SecurityOrigin::Create(url); scoped_refptr<const SecurityOrigin> origin = SecurityOrigin::Create(url);
self_protocol_ = origin->Protocol(); self_protocol_ = origin->Protocol();
self_source_ = MakeGarbageCollected<CSPSource>( self_source_ = network::mojom::blink::CSPSource::New(
this, self_protocol_, origin->Host(), origin->Port(), String(), self_protocol_, origin->Host(), origin->Port(), "",
CSPSource::kNoWildcard, CSPSource::kNoWildcard); /*is_host_wildcard=*/false, /*is_port_wildcard=*/false);
} }
Vector<CSPHeaderAndType> ContentSecurityPolicy::Headers() const { Vector<CSPHeaderAndType> ContentSecurityPolicy::Headers() const {
...@@ -1478,7 +1476,8 @@ bool ContentSecurityPolicy::ShouldSendCSPHeader(ResourceType type) const { ...@@ -1478,7 +1476,8 @@ bool ContentSecurityPolicy::ShouldSendCSPHeader(ResourceType type) const {
} }
bool ContentSecurityPolicy::UrlMatchesSelf(const KURL& url) const { bool ContentSecurityPolicy::UrlMatchesSelf(const KURL& url) const {
return self_source_->MatchesAsSelf(url); DCHECK(self_source_);
return CSPSourceMatchesAsSelf(*self_source_, self_protocol_, url);
} }
bool ContentSecurityPolicy::ProtocolEqualsSelf(const String& protocol) const { bool ContentSecurityPolicy::ProtocolEqualsSelf(const String& protocol) const {
......
...@@ -64,7 +64,6 @@ namespace blink { ...@@ -64,7 +64,6 @@ namespace blink {
class ContentSecurityPolicyResponseHeaders; class ContentSecurityPolicyResponseHeaders;
class ConsoleMessage; class ConsoleMessage;
class CSPDirectiveList; class CSPDirectiveList;
class CSPSource;
class DOMWrapperWorld; class DOMWrapperWorld;
class Element; class Element;
class ExecutionContext; class ExecutionContext;
...@@ -443,7 +442,9 @@ class CORE_EXPORT ContentSecurityPolicy final ...@@ -443,7 +442,9 @@ class CORE_EXPORT ContentSecurityPolicy final
bool ShouldSendCSPHeader(ResourceType) const; bool ShouldSendCSPHeader(ResourceType) const;
CSPSource* GetSelfSource() const { return self_source_; } network::mojom::blink::CSPSource* GetSelfSource() const {
return self_source_.get();
}
// Whether the main world's CSP should be bypassed based on the current // Whether the main world's CSP should be bypassed based on the current
// javascript world we are in. // javascript world we are in.
...@@ -595,7 +596,7 @@ class CORE_EXPORT ContentSecurityPolicy final ...@@ -595,7 +596,7 @@ class CORE_EXPORT ContentSecurityPolicy final
String disable_eval_error_message_; String disable_eval_error_message_;
mojom::blink::InsecureRequestPolicy insecure_request_policy_; mojom::blink::InsecureRequestPolicy insecure_request_policy_;
Member<CSPSource> self_source_; network::mojom::blink::CSPSourcePtr self_source_;
String self_protocol_; String self_protocol_;
bool supports_wasm_eval_ = false; bool supports_wasm_eval_ = false;
......
...@@ -980,7 +980,7 @@ void CSPDirectiveList::ParseReportURI(const String& name, const String& value) { ...@@ -980,7 +980,7 @@ void CSPDirectiveList::ParseReportURI(const String& name, const String& value) {
return false; return false;
} }
if (MixedContentChecker::IsMixedContent( if (MixedContentChecker::IsMixedContent(
policy_->GetSelfSource()->GetScheme(), policy_->GetSelfSource()->scheme,
parsed_endpoint)) { parsed_endpoint)) {
policy_->ReportMixedContentReportURI(endpoint); policy_->ReportMixedContentReportURI(endpoint);
return true; return true;
...@@ -1431,9 +1431,7 @@ CSPDirectiveList::ExposeForNavigationalChecks() const { ...@@ -1431,9 +1431,7 @@ CSPDirectiveList::ExposeForNavigationalChecks() const {
auto policy = network::mojom::blink::ContentSecurityPolicy::New(); auto policy = network::mojom::blink::ContentSecurityPolicy::New();
policy->self_origin = policy->self_origin =
policy_->GetSelfSource() policy_->GetSelfSource() ? policy_->GetSelfSource()->Clone() : nullptr;
? policy_->GetSelfSource()->ExposeForNavigationalChecks()
: nullptr;
policy->use_reporting_api = use_reporting_api_; policy->use_reporting_api = use_reporting_api_;
policy->report_endpoints = report_endpoints_; policy->report_endpoints = report_endpoints_;
policy->header = network::mojom::blink::ContentSecurityPolicyHeader::New( policy->header = network::mojom::blink::ContentSecurityPolicyHeader::New(
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
#ifndef THIRD_PARTY_BLINK_RENDERER_CORE_FRAME_CSP_CSP_SOURCE_H_ #ifndef THIRD_PARTY_BLINK_RENDERER_CORE_FRAME_CSP_CSP_SOURCE_H_
#define THIRD_PARTY_BLINK_RENDERER_CORE_FRAME_CSP_CSP_SOURCE_H_ #define THIRD_PARTY_BLINK_RENDERER_CORE_FRAME_CSP_CSP_SOURCE_H_
#include "services/network/public/mojom/content_security_policy.mojom-blink-forward.h"
#include "third_party/blink/public/platform/web_content_security_policy_struct.h" #include "third_party/blink/public/platform/web_content_security_policy_struct.h"
#include "third_party/blink/renderer/core/core_export.h" #include "third_party/blink/renderer/core/core_export.h"
#include "third_party/blink/renderer/core/frame/csp/content_security_policy.h" #include "third_party/blink/renderer/core/frame/csp/content_security_policy.h"
...@@ -15,86 +16,22 @@ ...@@ -15,86 +16,22 @@
namespace blink { namespace blink {
class ContentSecurityPolicy;
class KURL; class KURL;
class CORE_EXPORT CSPSource final : public GarbageCollected<CSPSource> { CORE_EXPORT
public: bool CSPSourceIsSchemeOnly(const network::mojom::blink::CSPSource& source);
// Represents the absence of a port.
const static int kPortUnspecified;
enum WildcardDisposition { kNoWildcard, kHasWildcard }; CORE_EXPORT
bool CSPSourceMatches(const network::mojom::blink::CSPSource& source,
const String& self_protocol,
const KURL& url,
ResourceRequest::RedirectStatus =
ResourceRequest::RedirectStatus::kNoRedirect);
// NotMatching is the only negative member, the rest are different types of CORE_EXPORT
// matches. NotMatching should always be 0 to let if statements work nicely bool CSPSourceMatchesAsSelf(const network::mojom::blink::CSPSource& source,
enum class PortMatchingResult { const String& self_protocol,
kNotMatching, const KURL& url);
kMatchingWildcard,
kMatchingUpgrade,
kMatchingExact
};
enum class SchemeMatchingResult {
kNotMatching,
kMatchingUpgrade,
kMatchingExact
};
CSPSource(ContentSecurityPolicy*,
const String& scheme,
const String& host,
int port,
const String& path,
WildcardDisposition host_wildcard,
WildcardDisposition port_wildcard);
CSPSource(ContentSecurityPolicy* policy, const CSPSource& other);
bool IsSchemeOnly() const;
const String& GetScheme() { return scheme_; }
bool Matches(const KURL&,
ResourceRequest::RedirectStatus =
ResourceRequest::RedirectStatus::kNoRedirect) const;
bool MatchesAsSelf(const KURL&);
network::mojom::blink::CSPSourcePtr ExposeForNavigationalChecks() const;
void Trace(Visitor*) const;
private:
FRIEND_TEST_ALL_PREFIXES(CSPDirectiveListTest, OperativeDirectiveGivenType);
SchemeMatchingResult SchemeMatches(const String&) const;
bool HostMatches(const String&) const;
bool PathMatches(const String&) const;
// Protocol is necessary to determine default port if it is zero.
PortMatchingResult PortMatches(int port, const String& protocol) const;
// Helper inline functions for Port and Scheme MatchingResult enums
bool inline RequiresUpgrade(const PortMatchingResult result) const {
return result == PortMatchingResult::kMatchingUpgrade;
}
bool inline RequiresUpgrade(const SchemeMatchingResult result) const {
return result == SchemeMatchingResult::kMatchingUpgrade;
}
bool inline CanUpgrade(const PortMatchingResult result) const {
return result == PortMatchingResult::kMatchingUpgrade ||
result == PortMatchingResult::kMatchingWildcard;
}
bool inline CanUpgrade(const SchemeMatchingResult result) const {
return result == SchemeMatchingResult::kMatchingUpgrade;
}
Member<ContentSecurityPolicy> policy_;
String scheme_;
String host_;
int port_;
String path_;
WildcardDisposition host_wildcard_;
WildcardDisposition port_wildcard_;
};
} // namespace blink } // namespace blink
......
...@@ -160,10 +160,14 @@ void SourceListDirective::Parse(const UChar* begin, const UChar* end) { ...@@ -160,10 +160,14 @@ void SourceListDirective::Parse(const UChar* begin, const UChar* end) {
const UChar* begin_source = position; const UChar* begin_source = position;
SkipWhile<UChar, IsSourceCharacter>(position, end); SkipWhile<UChar, IsSourceCharacter>(position, end);
String scheme, host, path; // We need to initialize all strings, since they can't be null in the mojo
int port = CSPSource::kPortUnspecified; // struct.
CSPSource::WildcardDisposition host_wildcard = CSPSource::kNoWildcard; String scheme = "";
CSPSource::WildcardDisposition port_wildcard = CSPSource::kNoWildcard; String host = "";
String path = "";
int port = url::PORT_UNSPECIFIED;
bool host_wildcard = false;
bool port_wildcard = false;
if (ParseSource(begin_source, position, &scheme, &host, &port, &path, if (ParseSource(begin_source, position, &scheme, &host, &port, &path,
&host_wildcard, &port_wildcard)) { &host_wildcard, &port_wildcard)) {
...@@ -175,8 +179,8 @@ void SourceListDirective::Parse(const UChar* begin, const UChar* end) { ...@@ -175,8 +179,8 @@ void SourceListDirective::Parse(const UChar* begin, const UChar* end) {
if (ContentSecurityPolicy::GetDirectiveType(host) != if (ContentSecurityPolicy::GetDirectiveType(host) !=
ContentSecurityPolicy::DirectiveType::kUndefined) ContentSecurityPolicy::DirectiveType::kUndefined)
policy_->ReportDirectiveAsSourceExpression(directive_name_, host); policy_->ReportDirectiveAsSourceExpression(directive_name_, host);
list_.push_back(MakeGarbageCollected<CSPSource>( list_.push_back(network::mojom::blink::CSPSource::New(
policy_, scheme, host, port, path, host_wildcard, port_wildcard)); scheme, host, port, path, host_wildcard, port_wildcard));
} else { } else {
policy_->ReportInvalidSourceExpression( policy_->ReportInvalidSourceExpression(
directive_name_, String(begin_source, static_cast<wtf_size_t>( directive_name_, String(begin_source, static_cast<wtf_size_t>(
...@@ -190,15 +194,14 @@ void SourceListDirective::Parse(const UChar* begin, const UChar* end) { ...@@ -190,15 +194,14 @@ void SourceListDirective::Parse(const UChar* begin, const UChar* end) {
// source = scheme ":" // source = scheme ":"
// / ( [ scheme "://" ] host [ port ] [ path ] ) // / ( [ scheme "://" ] host [ port ] [ path ] )
// / "'self'" // / "'self'"
bool SourceListDirective::ParseSource( bool SourceListDirective::ParseSource(const UChar* begin,
const UChar* begin, const UChar* end,
const UChar* end, String* scheme,
String* scheme, String* host,
String* host, int* port,
int* port, String* path,
String* path, bool* host_wildcard,
CSPSource::WildcardDisposition* host_wildcard, bool* port_wildcard) {
CSPSource::WildcardDisposition* port_wildcard) {
if (begin == end) if (begin == end)
return false; return false;
...@@ -339,7 +342,7 @@ bool SourceListDirective::ParseSource( ...@@ -339,7 +342,7 @@ bool SourceListDirective::ParseSource(
if (!ParsePort(begin_port, begin_path, port, port_wildcard)) if (!ParsePort(begin_port, begin_path, port, port_wildcard))
return false; return false;
} else { } else {
*port = CSPSource::kPortUnspecified; *port = url::PORT_UNSPECIFIED;
} }
if (begin_path != end) { if (begin_path != end) {
...@@ -480,14 +483,13 @@ bool SourceListDirective::ParseScheme(const UChar* begin, ...@@ -480,14 +483,13 @@ bool SourceListDirective::ParseScheme(const UChar* begin,
// host-char = ALPHA / DIGIT / "-" // host-char = ALPHA / DIGIT / "-"
// //
// static // static
bool SourceListDirective::ParseHost( bool SourceListDirective::ParseHost(const UChar* begin,
const UChar* begin, const UChar* end,
const UChar* end, String* host,
String* host, bool* host_wildcard) {
CSPSource::WildcardDisposition* host_wildcard) {
DCHECK(begin <= end); DCHECK(begin <= end);
DCHECK(host->IsEmpty()); DCHECK(host->IsEmpty());
DCHECK(*host_wildcard == CSPSource::kNoWildcard); DCHECK(!*host_wildcard);
if (begin == end) if (begin == end)
return false; return false;
...@@ -496,7 +498,7 @@ bool SourceListDirective::ParseHost( ...@@ -496,7 +498,7 @@ bool SourceListDirective::ParseHost(
// Parse "*" or [ "*." ]. // Parse "*" or [ "*." ].
if (SkipExactly<UChar>(position, end, '*')) { if (SkipExactly<UChar>(position, end, '*')) {
*host_wildcard = CSPSource::kHasWildcard; *host_wildcard = true;
if (position == end) { if (position == end) {
// "*" // "*"
...@@ -553,14 +555,13 @@ bool SourceListDirective::ParsePath(const UChar* begin, ...@@ -553,14 +555,13 @@ bool SourceListDirective::ParsePath(const UChar* begin,
// port = ":" ( 1*DIGIT / "*" ) // port = ":" ( 1*DIGIT / "*" )
// //
bool SourceListDirective::ParsePort( bool SourceListDirective::ParsePort(const UChar* begin,
const UChar* begin, const UChar* end,
const UChar* end, int* port,
int* port, bool* port_wildcard) {
CSPSource::WildcardDisposition* port_wildcard) {
DCHECK(begin <= end); DCHECK(begin <= end);
DCHECK_EQ(*port, CSPSource::kPortUnspecified); DCHECK_EQ(*port, url::PORT_UNSPECIFIED);
DCHECK(*port_wildcard == CSPSource::kNoWildcard); DCHECK(!*port_wildcard);
if (!SkipExactly<UChar>(begin, end, ':')) if (!SkipExactly<UChar>(begin, end, ':'))
NOTREACHED(); NOTREACHED();
...@@ -569,8 +570,8 @@ bool SourceListDirective::ParsePort( ...@@ -569,8 +570,8 @@ bool SourceListDirective::ParsePort(
return false; return false;
if (end - begin == 1 && *begin == '*') { if (end - begin == 1 && *begin == '*') {
*port = CSPSource::kPortUnspecified; *port = url::PORT_UNSPECIFIED;
*port_wildcard = CSPSource::kHasWildcard; *port_wildcard = true;
return true; return true;
} }
...@@ -636,9 +637,11 @@ void SourceListDirective::AddSourceHash( ...@@ -636,9 +637,11 @@ void SourceListDirective::AddSourceHash(
bool SourceListDirective::HasSourceMatchInList( bool SourceListDirective::HasSourceMatchInList(
const KURL& url, const KURL& url,
ResourceRequest::RedirectStatus redirect_status) const { ResourceRequest::RedirectStatus redirect_status) const {
for (wtf_size_t i = 0; i < list_.size(); ++i) { for (const auto& source : list_) {
if (list_[i]->Matches(url, redirect_status)) if (CSPSourceMatches(*source, policy_->GetSelfProtocol(), url,
redirect_status)) {
return true; return true;
}
} }
return false; return false;
...@@ -661,7 +664,7 @@ network::mojom::blink::CSPSourceListPtr ...@@ -661,7 +664,7 @@ network::mojom::blink::CSPSourceListPtr
SourceListDirective::ExposeForNavigationalChecks() const { SourceListDirective::ExposeForNavigationalChecks() const {
WTF::Vector<network::mojom::blink::CSPSourcePtr> sources; WTF::Vector<network::mojom::blink::CSPSourcePtr> sources;
for (const auto& source : list_) for (const auto& source : list_)
sources.push_back(source->ExposeForNavigationalChecks()); sources.push_back(source.Clone());
// We do not need nonces and hashes for navigational checks // We do not need nonces and hashes for navigational checks
WTF::Vector<WTF::String> nonces; WTF::Vector<WTF::String> nonces;
...@@ -675,7 +678,6 @@ SourceListDirective::ExposeForNavigationalChecks() const { ...@@ -675,7 +678,6 @@ SourceListDirective::ExposeForNavigationalChecks() const {
void SourceListDirective::Trace(Visitor* visitor) const { void SourceListDirective::Trace(Visitor* visitor) const {
visitor->Trace(policy_); visitor->Trace(policy_);
visitor->Trace(list_);
CSPDirective::Trace(visitor); CSPDirective::Trace(visitor);
} }
......
...@@ -71,17 +71,17 @@ class CORE_EXPORT SourceListDirective final : public CSPDirective { ...@@ -71,17 +71,17 @@ class CORE_EXPORT SourceListDirective final : public CSPDirective {
String* host, String* host,
int* port, int* port,
String* path, String* path,
CSPSource::WildcardDisposition*, bool* is_host_wildcard,
CSPSource::WildcardDisposition*); bool* is_port_wildcard);
bool ParseScheme(const UChar* begin, const UChar* end, String* scheme); bool ParseScheme(const UChar* begin, const UChar* end, String* scheme);
static bool ParseHost(const UChar* begin, static bool ParseHost(const UChar* begin,
const UChar* end, const UChar* end,
String* host, String* host,
CSPSource::WildcardDisposition*); bool* is_host_wildcard);
bool ParsePort(const UChar* begin, bool ParsePort(const UChar* begin,
const UChar* end, const UChar* end,
int* port, int* port,
CSPSource::WildcardDisposition*); bool* is_port_wildcard);
bool ParsePath(const UChar* begin, const UChar* end, String* path); bool ParsePath(const UChar* begin, const UChar* end, String* path);
bool ParseNonce(const UChar* begin, const UChar* end, String* nonce); bool ParseNonce(const UChar* begin, const UChar* end, String* nonce);
bool ParseHash(const UChar* begin, bool ParseHash(const UChar* begin,
...@@ -105,7 +105,7 @@ class CORE_EXPORT SourceListDirective final : public CSPDirective { ...@@ -105,7 +105,7 @@ class CORE_EXPORT SourceListDirective final : public CSPDirective {
bool HasSourceMatchInList(const KURL&, ResourceRequest::RedirectStatus) const; bool HasSourceMatchInList(const KURL&, ResourceRequest::RedirectStatus) const;
Member<ContentSecurityPolicy> policy_; Member<ContentSecurityPolicy> policy_;
HeapVector<Member<CSPSource>> list_; WTF::Vector<network::mojom::blink::CSPSourcePtr> list_;
String directive_name_; String directive_name_;
bool allow_self_; bool allow_self_;
bool allow_star_; bool allow_star_;
......
...@@ -26,8 +26,8 @@ class SourceListDirectiveTest : public testing::Test { ...@@ -26,8 +26,8 @@ class SourceListDirectiveTest : public testing::Test {
String host; String host;
const int port; const int port;
String path; String path;
CSPSource::WildcardDisposition host_wildcard; bool host_wildcard;
CSPSource::WildcardDisposition port_wildcard; bool port_wildcard;
}; };
void SetUp() override { void SetUp() override {
...@@ -430,7 +430,7 @@ TEST_F(SourceListDirectiveTest, ParseHost) { ...@@ -430,7 +430,7 @@ TEST_F(SourceListDirectiveTest, ParseHost) {
for (const auto& test : cases) { for (const auto& test : cases) {
String host; String host;
CSPSource::WildcardDisposition disposition = CSPSource::kNoWildcard; bool disposition = false;
Vector<UChar> characters; Vector<UChar> characters;
test.sources.AppendTo(characters); test.sources.AppendTo(characters);
const UChar* start = characters.data(); const UChar* start = characters.data();
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment