| // Copyright 2012 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "sandbox/win/src/broker_services.h" |
| |
| #include <stddef.h> |
| |
| #include <optional> |
| #include <utility> |
| #include "base/check_op.h" |
| #include "base/containers/contains.h" |
| #include "base/memory/ptr_util.h" |
| #include "base/notreached.h" |
| #include "base/threading/platform_thread.h" |
| #include "base/win/access_token.h" |
| #include "base/win/current_module.h" |
| #include "base/win/scoped_handle.h" |
| #include "base/win/scoped_process_information.h" |
| #include "base/win/windows_version.h" |
| #include "build/build_config.h" |
| #include "sandbox/win/src/app_container.h" |
| #include "sandbox/win/src/process_mitigations.h" |
| #include "sandbox/win/src/sandbox.h" |
| #include "sandbox/win/src/sandbox_policy_base.h" |
| #include "sandbox/win/src/sandbox_policy_diagnostic.h" |
| #include "sandbox/win/src/startup_information_helper.h" |
| #include "sandbox/win/src/target_process.h" |
| #include "sandbox/win/src/threadpool.h" |
| #include "sandbox/win/src/win_utils.h" |
| |
| namespace { |
| |
| // Utility function to associate a completion port to a job object. |
| bool AssociateCompletionPort(HANDLE job, HANDLE port, void* key) { |
| JOBOBJECT_ASSOCIATE_COMPLETION_PORT job_acp = {key, port}; |
| return ::SetInformationJobObject(job, |
| JobObjectAssociateCompletionPortInformation, |
| &job_acp, sizeof(job_acp)) |
| ? true |
| : false; |
| } |
| |
| // Commands that can be sent to the completion port serviced by |
| // TargetEventsThread(). |
| enum { |
| THREAD_CTRL_NONE, |
| THREAD_CTRL_NEW_JOB_TRACKER, |
| THREAD_CTRL_GET_POLICY_INFO, |
| THREAD_CTRL_QUIT, |
| THREAD_CTRL_LAST, |
| }; |
| |
| // Transfers parameters to the target events thread during Init(). |
| struct TargetEventsThreadParams { |
| TargetEventsThreadParams( |
| HANDLE iocp, |
| std::unique_ptr<sandbox::BrokerServicesTargetTracker> target_tracker, |
| std::unique_ptr<sandbox::ThreadPool> thread_pool) |
| : iocp(iocp), |
| target_tracker_(std::move(target_tracker)), |
| thread_pool(std::move(thread_pool)) {} |
| ~TargetEventsThreadParams() {} |
| // IOCP that job notifications and commands are sent to. |
| // Handle is closed when BrokerServices is destroyed. |
| HANDLE iocp; |
| // Used in tests to keep track of how many processes are in jobs. Should be |
| // nullptr in production. |
| std::unique_ptr<sandbox::BrokerServicesTargetTracker> target_tracker_; |
| // Thread pool used to mediate sandbox IPC, owned by the target |
| // events thread but accessed by BrokerServices and TargetProcesses. |
| // Destroyed when TargetEventsThread ends. |
| std::unique_ptr<sandbox::ThreadPool> thread_pool; |
| }; |
| |
| // Helper structure that allows the Broker to associate a job notification |
| // with a job object and with a policy. |
| struct JobTracker { |
| JobTracker(std::unique_ptr<sandbox::PolicyBase> policy, DWORD process_id) |
| : policy(std::move(policy)), process_id(process_id) {} |
| ~JobTracker() { |
| // As if TerminateProcess() was called for all associated processes. |
| // Handles are still valid. |
| ::TerminateJobObject(policy->GetJobHandle(), sandbox::SBOX_ALL_OK); |
| } |
| |
| std::unique_ptr<sandbox::PolicyBase> policy; |
| DWORD process_id; |
| }; |
| |
| // Helper class to send policy lists |
| class PolicyDiagnosticList final : public sandbox::PolicyList { |
| public: |
| PolicyDiagnosticList() {} |
| ~PolicyDiagnosticList() override {} |
| void push_back(std::unique_ptr<sandbox::PolicyInfo> info) { |
| internal_list_.push_back(std::move(info)); |
| } |
| std::vector<std::unique_ptr<sandbox::PolicyInfo>>::iterator begin() override { |
| return internal_list_.begin(); |
| } |
| std::vector<std::unique_ptr<sandbox::PolicyInfo>>::iterator end() override { |
| return internal_list_.end(); |
| } |
| size_t size() const override { return internal_list_.size(); } |
| |
| private: |
| std::vector<std::unique_ptr<sandbox::PolicyInfo>> internal_list_; |
| }; |
| |
| // The worker thread stays in a loop waiting for asynchronous notifications |
| // from the job objects. Right now we only care about knowing when the last |
| // process on a job terminates, but in general this is the place to tell |
| // the policy about events. |
| DWORD WINAPI TargetEventsThread(PVOID param) { |
| if (!param) |
| return 1; |
| |
| base::PlatformThread::SetName("BrokerEvent"); |
| |
| // Take ownership of params so that it is deleted on thread exit. |
| std::unique_ptr<TargetEventsThreadParams> params( |
| reinterpret_cast<TargetEventsThreadParams*>(param)); |
| |
| std::list<std::unique_ptr<JobTracker>> jobs; |
| |
| while (true) { |
| DWORD event = 0; |
| ULONG_PTR key = 0; |
| LPOVERLAPPED ovl = nullptr; |
| |
| if (!::GetQueuedCompletionStatus(params->iocp, &event, &key, &ovl, |
| INFINITE)) { |
| // This call fails if the port has been closed before we have a |
| // chance to service the last packet which is 'exit' anyway so |
| // this is not an error. |
| return 1; |
| } |
| |
| if (key > THREAD_CTRL_LAST) { |
| // The notification comes from a job object. There are nine notifications |
| // that jobs can send and some of them depend on the job attributes set. |
| JobTracker* tracker = reinterpret_cast<JobTracker*>(key); |
| |
| // Processes may be added to a job after the process count has reached |
| // zero, leading us to manipulate a freed JobTracker object or job handle |
| // (as the key is no longer valid). We therefore check if the tracker has |
| // already been deleted. Note that Windows may emit notifications after |
| // 'job finished' (active process zero), so not every case is unexpected. |
| if (!base::Contains(jobs, tracker, &std::unique_ptr<JobTracker>::get)) { |
| // CHECK if job already deleted. |
| CHECK_NE(static_cast<int>(event), JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO); |
| // Continue to next notification otherwise. |
| continue; |
| } |
| |
| switch (event) { |
| case JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO: { |
| // The job object has signaled that the last process associated |
| // with it has terminated. It is safe to free the tracker |
| // and release its reference to the associated policy object |
| // which will Close the job handle. |
| jobs.erase(std::remove_if( |
| jobs.begin(), jobs.end(), |
| [&](auto&& p) -> bool { return p.get() == tracker; }), |
| jobs.end()); |
| break; |
| } |
| |
| case JOB_OBJECT_MSG_NEW_PROCESS: { |
| // Child process created from sandboxed process. |
| if (params->target_tracker_) { |
| params->target_tracker_->OnTargetAdded(); |
| } |
| break; |
| } |
| |
| case JOB_OBJECT_MSG_EXIT_PROCESS: |
| case JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS: { |
| if (params->target_tracker_) { |
| params->target_tracker_->OnTargetRemoved(); |
| } |
| break; |
| } |
| |
| case JOB_OBJECT_MSG_ACTIVE_PROCESS_LIMIT: { |
| // A child process attempted and failed to create a child process. |
| // Counters must increment here as Windows will also send us a |
| // JOB_OBJECT_MSG_EXIT_PROCESS notification for the failed-to-start |
| // process. |
| // Windows does not reveal the process id. |
| if (params->target_tracker_) { |
| params->target_tracker_->OnTargetAdded(); |
| } |
| break; |
| } |
| |
| case JOB_OBJECT_MSG_PROCESS_MEMORY_LIMIT: { |
| bool res = ::TerminateJobObject(tracker->policy->GetJobHandle(), |
| sandbox::SBOX_FATAL_MEMORY_EXCEEDED); |
| DCHECK(res); |
| // We also get the ACTIVE_PROCESS_ZERO event which reaps the job. |
| if (params->target_tracker_) { |
| params->target_tracker_->OnTargetRemoved(); |
| } |
| break; |
| } |
| |
| default: { |
| NOTREACHED(); |
| break; |
| } |
| } |
| } else if (THREAD_CTRL_NEW_JOB_TRACKER == key) { |
| std::unique_ptr<JobTracker> tracker; |
| tracker.reset(reinterpret_cast<JobTracker*>(ovl)); |
| DCHECK(tracker->policy->HasJob()); |
| |
| jobs.push_back(std::move(tracker)); |
| } else if (THREAD_CTRL_GET_POLICY_INFO == key) { |
| // Clone the policies for sandbox diagnostics. |
| std::unique_ptr<sandbox::PolicyDiagnosticsReceiver> receiver; |
| receiver.reset(static_cast<sandbox::PolicyDiagnosticsReceiver*>( |
| reinterpret_cast<void*>(ovl))); |
| // The PollicyInfo ctor copies essential information from the trackers. |
| auto policy_list = std::make_unique<PolicyDiagnosticList>(); |
| for (auto&& job_tracker : jobs) { |
| if (job_tracker->policy) { |
| policy_list->push_back(std::make_unique<sandbox::PolicyDiagnostic>( |
| job_tracker->policy.get())); |
| } |
| } |
| // Receiver should return quickly. |
| receiver->ReceiveDiagnostics(std::move(policy_list)); |
| |
| } else if (THREAD_CTRL_QUIT == key) { |
| // After this point, so further calls to ProcessEventCallback can |
| // occur. Other tracked objects are destroyed as this thread ends. |
| return 0; |
| } else { |
| // We have not implemented more commands. |
| NOTREACHED(); |
| } |
| } |
| |
| NOTREACHED(); |
| return 0; |
| } |
| |
| } // namespace |
| |
| namespace sandbox { |
| |
| BrokerServicesBase::BrokerServicesBase() {} |
| |
| // The broker uses a dedicated worker thread that services the job completion |
| // port to perform policy notifications and associated cleanup tasks. |
| ResultCode BrokerServicesBase::Init( |
| std::unique_ptr<BrokerServicesTargetTracker> target_tracker) { |
| if (job_port_.is_valid() || thread_pool_) { |
| return SBOX_ERROR_UNEXPECTED_CALL; |
| } |
| |
| job_port_.Set(::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, 0)); |
| if (!job_port_.is_valid()) { |
| return SBOX_ERROR_CANNOT_INIT_BROKERSERVICES; |
| } |
| |
| // We transfer ownership of this memory to the thread. |
| auto params = std::make_unique<TargetEventsThreadParams>( |
| job_port_.get(), std::move(target_tracker), |
| std::make_unique<ThreadPool>()); |
| |
| // We keep the thread alive until our destructor so we can use a raw |
| // pointer to the thread pool. |
| thread_pool_ = params->thread_pool.get(); |
| |
| #if defined(ARCH_CPU_32_BITS) |
| // Conserve address space in 32-bit Chrome. This thread uses a small and |
| // consistent amount and doesn't need the default of 1.5 MiB. |
| constexpr unsigned flags = STACK_SIZE_PARAM_IS_A_RESERVATION; |
| constexpr size_t stack_size = 128 * 1024; |
| #else |
| constexpr unsigned int flags = 0; |
| constexpr size_t stack_size = 0; |
| #endif |
| job_thread_.Set(::CreateThread(nullptr, stack_size, // Default security. |
| TargetEventsThread, params.get(), flags, |
| nullptr)); |
| if (!job_thread_.is_valid()) { |
| thread_pool_ = nullptr; |
| // Returning cleans up params. |
| return SBOX_ERROR_CANNOT_INIT_BROKERSERVICES; |
| } |
| |
| params.release(); |
| return SBOX_ALL_OK; |
| } |
| |
| ResultCode BrokerServicesBase::Init() { |
| return BrokerServicesBase::Init(nullptr); |
| } |
| |
| // Only called in test code. |
| ResultCode BrokerServicesBase::InitForTesting( |
| std::unique_ptr<BrokerServicesTargetTracker> target_tracker) { |
| return BrokerServicesBase::Init(std::move(target_tracker)); |
| } |
| |
| // The destructor should only be called when the Broker process is terminating. |
| // Since BrokerServicesBase is a singleton, this is called from the CRT |
| // termination handlers, if this code lives on a DLL it is called during |
| // DLL_PROCESS_DETACH in other words, holding the loader lock, so we cannot |
| // wait for threads here. |
| BrokerServicesBase::~BrokerServicesBase() { |
| // If there is no port Init() was never called successfully. |
| if (!job_port_.is_valid()) { |
| return; |
| } |
| |
| // Closing the port causes, that no more Job notifications are delivered to |
| // the worker thread and also causes the thread to exit. This is what we |
| // want to do since we are going to close all outstanding Jobs and notifying |
| // the policy objects ourselves. |
| ::PostQueuedCompletionStatus(job_port_.get(), 0, THREAD_CTRL_QUIT, nullptr); |
| |
| if (job_thread_.is_valid() && |
| WAIT_TIMEOUT == ::WaitForSingleObject(job_thread_.get(), 5000)) { |
| // Cannot clean broker services. |
| NOTREACHED(); |
| return; |
| } |
| } |
| |
| std::unique_ptr<TargetPolicy> BrokerServicesBase::CreatePolicy() { |
| return CreatePolicy(""); |
| } |
| |
| std::unique_ptr<TargetPolicy> BrokerServicesBase::CreatePolicy( |
| std::string_view tag) { |
| // If you change the type of the object being created here you must also |
| // change the downcast to it in SpawnTarget(). |
| auto policy = std::make_unique<PolicyBase>(tag); |
| // Empty key implies we will not use the store. The policy will need |
| // to look after its config. |
| if (!tag.empty()) { |
| // Otherwise the broker owns the memory, not the policy. |
| auto found = config_cache_.find(tag); |
| ConfigBase* shared_config = nullptr; |
| if (found == config_cache_.end()) { |
| auto new_config = std::make_unique<ConfigBase>(); |
| shared_config = new_config.get(); |
| config_cache_[std::string(tag)] = std::move(new_config); |
| policy->SetConfig(shared_config); |
| } else { |
| policy->SetConfig(found->second.get()); |
| } |
| } |
| return policy; |
| } |
| |
| // SpawnTarget does all the interesting sandbox setup and creates the target |
| // process inside the sandbox. |
| ResultCode BrokerServicesBase::SpawnTarget(const wchar_t* exe_path, |
| const wchar_t* command_line, |
| std::unique_ptr<TargetPolicy> policy, |
| DWORD* last_error, |
| PROCESS_INFORMATION* target_info) { |
| if (!exe_path) |
| return SBOX_ERROR_BAD_PARAMS; |
| |
| // This code should only be called from the exe, ensure that this is always |
| // the case. |
| HMODULE exe_module = nullptr; |
| CHECK(::GetModuleHandleEx( |
| /*dwFlags=*/GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, nullptr, |
| &exe_module)); |
| if (CURRENT_MODULE() != exe_module) |
| return SBOX_ERROR_INVALID_LINK_STATE; |
| |
| if (!policy) |
| return SBOX_ERROR_BAD_PARAMS; |
| |
| // This downcast is safe as long as we control CreatePolicy(). |
| std::unique_ptr<PolicyBase> policy_base; |
| policy_base.reset(static_cast<PolicyBase*>(policy.release())); |
| // |policy| cannot be used from here onwards. |
| |
| ConfigBase* config_base = static_cast<ConfigBase*>(policy_base->GetConfig()); |
| if (!config_base->IsConfigured()) { |
| if (!config_base->Freeze()) |
| return SBOX_ERROR_FAILED_TO_FREEZE_CONFIG; |
| } |
| |
| // Even though the resources touched by SpawnTarget can be accessed in |
| // multiple threads, the method itself cannot be called from more than one |
| // thread. This is to protect the global variables used while setting up the |
| // child process, and to make sure launcher thread mitigations are applied |
| // correctly. |
| static DWORD thread_id = ::GetCurrentThreadId(); |
| DCHECK(thread_id == ::GetCurrentThreadId()); |
| |
| // Launcher thread only needs to be opted out of ACG once. Do this on the |
| // first child process being spawned. |
| static bool launcher_thread_opted_out = false; |
| |
| if (!launcher_thread_opted_out) { |
| // Soft fail this call. It will fail if ACG is not enabled for this process. |
| sandbox::ApplyMitigationsToCurrentThread( |
| sandbox::MITIGATION_DYNAMIC_CODE_OPT_OUT_THIS_THREAD); |
| launcher_thread_opted_out = true; |
| } |
| |
| // Construct the tokens and the job object that we are going to associate |
| // with the soon to be created target process. |
| std::optional<base::win::AccessToken> initial_token; |
| std::optional<base::win::AccessToken> lockdown_token; |
| ResultCode result = SBOX_ALL_OK; |
| |
| result = policy_base->MakeTokens(initial_token, lockdown_token); |
| if (SBOX_ALL_OK != result) |
| return result; |
| |
| result = UpdateDesktopIntegrity(config_base->desktop(), |
| config_base->integrity_level()); |
| if (result != SBOX_ALL_OK) |
| return result; |
| |
| result = policy_base->InitJob(); |
| if (SBOX_ALL_OK != result) |
| return result; |
| |
| // Initialize the startup information from the policy. |
| auto startup_info = std::make_unique<StartupInformationHelper>(); |
| |
| // We don't want any child processes causing the IDC_APPSTARTING cursor. |
| startup_info->UpdateFlags(STARTF_FORCEOFFFEEDBACK); |
| startup_info->SetDesktop(GetDesktopName(config_base->desktop())); |
| startup_info->SetMitigations(config_base->GetProcessMitigations()); |
| startup_info->SetFilterEnvironment(config_base->GetEnvironmentFiltered()); |
| |
| if (base::win::GetVersion() >= base::win::Version::WIN10_TH2 && |
| config_base->GetJobLevel() <= JobLevel::kLimitedUser) { |
| startup_info->SetRestrictChildProcessCreation(true); |
| } |
| |
| // Shares std handles if they are valid. |
| startup_info->SetStdHandles(policy_base->GetStdoutHandle(), |
| policy_base->GetStderrHandle()); |
| // Add any additional handles that were requested. |
| const auto& policy_handle_list = policy_base->GetHandlesBeingShared(); |
| for (HANDLE handle : policy_handle_list) |
| startup_info->AddInheritedHandle(handle); |
| |
| scoped_refptr<AppContainer> container = config_base->GetAppContainer(); |
| if (container) |
| startup_info->SetAppContainer(container); |
| |
| startup_info->AddJobToAssociate(policy_base->GetJobHandle()); |
| |
| if (!startup_info->BuildStartupInformation()) |
| return SBOX_ERROR_PROC_THREAD_ATTRIBUTES; |
| |
| // Create the TargetProcess object and spawn the target suspended. Note that |
| // Brokerservices does not own the target object. It is owned by the Policy. |
| base::win::ScopedProcessInformation process_info; |
| std::unique_ptr<TargetProcess> target = std::make_unique<TargetProcess>( |
| std::move(*initial_token), std::move(*lockdown_token), thread_pool_); |
| |
| result = target->Create(exe_path, command_line, std::move(startup_info), |
| &process_info, last_error); |
| |
| if (result != SBOX_ALL_OK) { |
| target->Terminate(); |
| return result; |
| } |
| |
| if (config_base->GetJobLevel() <= JobLevel::kLimitedUser) { |
| // Restrict the job from containing any processes. Job restrictions |
| // are only applied at process creation, so the target process is |
| // unaffected. |
| result = policy_base->DropActiveProcessLimit(); |
| if (result != SBOX_ALL_OK) { |
| target->Terminate(); |
| return result; |
| } |
| } |
| |
| // Now the policy is the owner of the target. TargetProcess will terminate |
| // the process if it has not completed when it is destroyed. |
| result = policy_base->ApplyToTarget(std::move(target)); |
| |
| if (result != SBOX_ALL_OK) { |
| *last_error = ::GetLastError(); |
| return result; |
| } |
| |
| HANDLE job_handle = policy_base->GetJobHandle(); |
| JobTracker* tracker = |
| new JobTracker(std::move(policy_base), process_info.process_id()); |
| |
| // Post the tracker to the tracking thread, then associate the job with |
| // the tracker. The worker thread takes ownership of these objects. |
| CHECK(::PostQueuedCompletionStatus(job_port_.get(), 0, |
| THREAD_CTRL_NEW_JOB_TRACKER, |
| reinterpret_cast<LPOVERLAPPED>(tracker))); |
| // There is no obvious cleanup here. |
| CHECK(AssociateCompletionPort(job_handle, job_port_.get(), tracker)); |
| |
| *target_info = process_info.Take(); |
| return result; |
| } |
| |
| ResultCode BrokerServicesBase::GetPolicyDiagnostics( |
| std::unique_ptr<PolicyDiagnosticsReceiver> receiver) { |
| CHECK(job_thread_.is_valid()); |
| // Post to the job thread. |
| if (!::PostQueuedCompletionStatus( |
| job_port_.get(), 0, THREAD_CTRL_GET_POLICY_INFO, |
| reinterpret_cast<LPOVERLAPPED>(receiver.get()))) { |
| receiver->OnError(SBOX_ERROR_GENERIC); |
| return SBOX_ERROR_GENERIC; |
| } |
| |
| // Ownership has passed to tracker thread. |
| receiver.release(); |
| return SBOX_ALL_OK; |
| } |
| |
| void BrokerServicesBase::SetStartingMitigations( |
| sandbox::MitigationFlags starting_mitigations) { |
| sandbox::SetStartingMitigations(starting_mitigations); |
| } |
| |
| bool BrokerServicesBase::RatchetDownSecurityMitigations( |
| MitigationFlags additional_flags) { |
| return sandbox::RatchetDownSecurityMitigations(additional_flags); |
| } |
| |
| std::wstring BrokerServicesBase::GetDesktopName(Desktop desktop) { |
| switch (desktop) { |
| case Desktop::kDefault: |
| // No alternate desktop or winstation. Return an empty string. |
| return std::wstring(); |
| case Desktop::kAlternateWinstation: |
| return alt_winstation_->GetDesktopName(); |
| case Desktop::kAlternateDesktop: |
| return alt_desktop_->GetDesktopName(); |
| } |
| } |
| |
| ResultCode BrokerServicesBase::UpdateDesktopIntegrity( |
| Desktop desktop, |
| IntegrityLevel integrity) { |
| // If we're launching on an alternate desktop we need to make sure the |
| // integrity label on the object is no higher than the sandboxed process's |
| // integrity level. So, we lower the label on the desktop handle if it's |
| // not already low enough for our process. |
| if (integrity == INTEGRITY_LEVEL_LAST) |
| return SBOX_ALL_OK; |
| switch (desktop) { |
| case Desktop::kDefault: |
| return SBOX_ALL_OK; |
| case Desktop::kAlternateWinstation: |
| return alt_winstation_->UpdateDesktopIntegrity(integrity); |
| case Desktop::kAlternateDesktop: |
| return alt_desktop_->UpdateDesktopIntegrity(integrity); |
| } |
| } |
| |
| ResultCode BrokerServicesBase::CreateAlternateDesktop(Desktop desktop) { |
| switch (desktop) { |
| case Desktop::kAlternateWinstation: { |
| // If already populated keep going. |
| if (alt_winstation_) |
| return SBOX_ALL_OK; |
| alt_winstation_ = std::make_unique<AlternateDesktop>(); |
| ResultCode result = alt_winstation_->Initialize(true); |
| if (result != SBOX_ALL_OK) |
| alt_winstation_.reset(); |
| return result; |
| }; |
| case Desktop::kAlternateDesktop: { |
| // If already populated keep going. |
| if (alt_desktop_) |
| return SBOX_ALL_OK; |
| alt_desktop_ = std::make_unique<AlternateDesktop>(); |
| ResultCode result = alt_desktop_->Initialize(false); |
| if (result != SBOX_ALL_OK) |
| alt_desktop_.reset(); |
| return result; |
| }; |
| case Desktop::kDefault: |
| // The default desktop always exists. |
| return SBOX_ALL_OK; |
| } |
| } |
| |
| void BrokerServicesBase::DestroyDesktops() { |
| alt_winstation_.reset(); |
| alt_desktop_.reset(); |
| } |
| |
| // static |
| void BrokerServicesBase::FreezeTargetConfigForTesting(TargetConfig* config) { |
| CHECK(!config->IsConfigured()); |
| static_cast<ConfigBase*>(config)->Freeze(); |
| } |
| |
| } // namespace sandbox |