| // Copyright 2019 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "tools/fidlcat/lib/interception_workflow.h" |
| |
| #include <sys/time.h> |
| |
| #include <cstring> |
| #include <string> |
| #include <thread> |
| |
| #include "src/developer/debug/shared/platform_message_loop.h" |
| #include "src/developer/debug/shared/zx_status.h" |
| #include "src/developer/debug/zxdb/client/breakpoint.h" |
| #include "src/developer/debug/zxdb/client/filter.h" |
| #include "src/developer/debug/zxdb/client/remote_api.h" |
| #include "src/developer/debug/zxdb/client/setting_schema_definition.h" |
| #include "src/developer/debug/zxdb/client/symbol_server.h" |
| #include "src/developer/debug/zxdb/client/thread.h" |
| #include "src/developer/debug/zxdb/expr/expr_parser.h" |
| #include "tools/fidlcat/lib/decode_options.h" |
| #include "tools/fidlcat/lib/syscall_decoder_dispatcher.h" |
| |
| namespace fidlcat { |
| |
| void InterceptingThreadObserver::OnThreadStopped(zxdb::Thread* thread, const zxdb::StopInfo& info) { |
| FX_CHECK(thread) << "Internal error: Stopped in a breakpoint without a thread?"; |
| |
| if (info.exception_type != debug_ipc::ExceptionType::kSoftwareBreakpoint) { |
| FX_CHECK(info.hit_breakpoints.empty()); |
| if (threads_in_error_.find(thread->GetKoid()) == threads_in_error_.end()) { |
| threads_in_error_.emplace(thread->GetKoid()); |
| workflow_->syscall_decoder_dispatcher()->DecodeException(workflow_, thread, info.timestamp); |
| } |
| return; |
| } |
| |
| if (info.hit_breakpoints.empty()) { |
| // This can happen when we are shutting down fidlcat. |
| // There is nothing to do => we just return. |
| return; |
| } |
| |
| // TODO(bug 47497) Uncomment this and fix the test bugs that create more than one breakpoint |
| // at the same address. |
| // FX_CHECK(info.hit_breakpoints.size() == 1) |
| // << "Internal error: more than one simultaneous breakpoint for thread " << |
| // thread->GetKoid(); |
| |
| // There a two possible breakpoints we can hit: |
| // - A breakpoint right before a system call (zx_channel_read, |
| // zx_channel_write, etc) |
| // - A breakpoint that we hit because we ran the system call to see what the |
| // result will be. |
| |
| // This is the breakpoint that we hit after running the system call. The |
| // initial breakpoint - the one on the system call - registered a callback in |
| // this per-thread map, so that the next breakpoint on this thread would be |
| // handled here. |
| auto entry = breakpoint_map_.find(thread->GetKoid()); |
| if (entry != breakpoint_map_.end()) { |
| entry->second->LoadSyscallReturnValue(); |
| // Erasing under the assumption that the next step will put it back, if |
| // necessary. |
| breakpoint_map_.erase(thread->GetKoid()); |
| return; |
| } |
| |
| // If there was no registered breakpoint on this thread, we hit it because we |
| // encountered a system call. Run the callbacks associated with this system |
| // call. |
| for (auto& bp_ptr : info.hit_breakpoints) { |
| zxdb::BreakpointSettings settings = bp_ptr->GetSettings(); |
| if (settings.locations.size() == 1u && |
| settings.locations[0].type == zxdb::InputLocation::Type::kName && |
| settings.locations[0].name.components().size() == 1u) { |
| threads_in_error_.erase(thread->GetKoid()); |
| // Compare against the syscall->name() which is the syscall name not including the $plt |
| // prefix. The Identifier component's name won't include this annotation without running |
| // GetFullName() which is slower. We already checked that it's a $plt annotation above. |
| auto syscall = workflow_->syscall_decoder_dispatcher()->SearchSyscall( |
| settings.locations[0].name.components()[0].name()); |
| if (syscall == nullptr) { |
| FX_LOGS(ERROR) << thread->GetProcess()->GetName() << ' ' << thread->GetProcess()->GetKoid() |
| << ':' << thread->GetKoid() << ": Internal error: breakpoint " |
| << settings.locations[0].name.components()[0].name() << " not managed"; |
| thread->Continue(false); |
| return; |
| } |
| workflow_->syscall_decoder_dispatcher()->DecodeSyscall(this, thread, syscall, info.timestamp); |
| return; |
| } |
| } |
| thread->Continue(false); |
| } |
| |
| void InterceptingThreadObserver::Register(int64_t koid, SyscallDecoder* decoder) { |
| breakpoint_map_[koid] = decoder; |
| } |
| |
| void InterceptingThreadObserver::AddExitBreakpoint(zxdb::Thread* thread, |
| const fidlcat::Syscall& syscall, |
| uint64_t address) { |
| zxdb::BreakpointSettings settings; |
| if (one_shot_breakpoints_) { |
| settings.enabled = true; |
| settings.name = syscall.name() + "-return"; |
| settings.stop_mode = zxdb::BreakpointSettings::StopMode::kThread; |
| settings.type = debug_ipc::BreakpointType::kSoftware; |
| settings.locations.emplace_back(address); |
| settings.scope = zxdb::ExecutionScope(thread); |
| settings.one_shot = true; |
| } else { |
| if (exit_breakpoints_.find(address) != exit_breakpoints_.end()) { |
| return; |
| } |
| |
| exit_breakpoints_.emplace(address); |
| |
| settings.enabled = true; |
| settings.name = syscall.name() + "-return"; |
| settings.stop_mode = zxdb::BreakpointSettings::StopMode::kThread; |
| settings.type = debug_ipc::BreakpointType::kSoftware; |
| settings.locations.emplace_back(address); |
| settings.scope = zxdb::ExecutionScope(thread->GetProcess()->GetTarget()); |
| } |
| |
| FX_VLOGS(2) << "Thread " << thread->GetKoid() << ": creating return value breakpoint for " |
| << syscall.name() << " at address " << std::hex << address << std::dec; |
| |
| if (syscall.exit_bp_instructions().size() > 0) { |
| settings.instructions = syscall.exit_bp_instructions(); |
| settings.has_automation = true; |
| } |
| CreateNewBreakpoint(thread, settings); |
| } |
| |
| void InterceptingThreadObserver::CreateNewBreakpoint(zxdb::Thread* thread, |
| zxdb::BreakpointSettings& settings) { |
| zxdb::Breakpoint* breakpoint = workflow_->session_->system().CreateNewBreakpoint(); |
| breakpoint->SetSettings(settings); |
| } |
| |
| void InterceptingProcessObserver::DidCreateProcess(zxdb::Process* process, bool autoattached, |
| uint64_t timestamp) { |
| workflow_->syscall_decoder_dispatcher()->AddLaunchedProcess(process->GetKoid()); |
| workflow_->SetBreakpoints(process, timestamp); |
| } |
| |
| void InterceptingProcessObserver::WillDestroyProcess(zxdb::Process* process, |
| ProcessObserver::DestroyReason reason, |
| int exit_code, uint64_t timestamp) { |
| workflow_->ProcessDetached(process->GetKoid(), timestamp); |
| } |
| |
| void InterceptingProcessObserver::OnSymbolLoadFailure(zxdb::Process* process, |
| const zxdb::Err& err) { |
| FX_LOGS(ERROR) << " cannot load symbols for process " << process->GetKoid() << ": " << err.msg(); |
| } |
| |
| InterceptionWorkflow::InterceptionWorkflow() |
| : session_(new zxdb::Session()), |
| delete_session_(true), |
| loop_(new debug::PlatformMessageLoop()), |
| delete_loop_(true), |
| process_observer_(this), |
| thread_observer_(this) { |
| std::string error; |
| if (!loop_->Init(&error)) { |
| FX_LOGS(ERROR) << error; |
| } |
| session_->process_observers().AddObserver(&process_observer_); |
| session_->thread_observers().AddObserver(&thread_observer_); |
| } |
| |
| InterceptionWorkflow::InterceptionWorkflow(zxdb::Session* session, debug::MessageLoop* loop) |
| : session_(session), |
| delete_session_(false), |
| loop_(loop), |
| delete_loop_(false), |
| process_observer_(this), |
| thread_observer_(this) { |
| session_->process_observers().AddObserver(&process_observer_); |
| session_->thread_observers().AddObserver(&thread_observer_); |
| } |
| |
| InterceptionWorkflow::~InterceptionWorkflow() { |
| session_->thread_observers().RemoveObserver(&thread_observer_); |
| session_->process_observers().RemoveObserver(&process_observer_); |
| if (delete_session_) { |
| delete session_; |
| } |
| if (delete_loop_) { |
| delete loop_; |
| } |
| } |
| |
| void InterceptionWorkflow::Initialize( |
| const std::vector<std::string>& symbol_index_files, |
| const std::vector<std::string>& symbol_paths, const std::vector<std::string>& build_id_dirs, |
| const std::vector<std::string>& ids_txts, const std::optional<std::string>& symbol_cache, |
| const std::vector<std::string>& symbol_servers, |
| std::unique_ptr<SyscallDecoderDispatcher> syscall_decoder_dispatcher, bool quit_agent_on_exit) { |
| syscall_decoder_dispatcher_ = std::move(syscall_decoder_dispatcher); |
| |
| if (quit_agent_on_exit) { |
| session_->system().settings().SetBool(zxdb::ClientSettings::System::kQuitAgentOnExit, true); |
| } |
| |
| // 1) Set up symbol index. |
| |
| // Please keep in sync with zxdb/console/console_main.cc |
| auto& system_settings = session_->system().settings(); |
| |
| if (symbol_cache) { |
| system_settings.SetString(zxdb::ClientSettings::System::kSymbolCache, *symbol_cache); |
| } |
| |
| if (!symbol_index_files.empty()) { |
| system_settings.SetList(zxdb::ClientSettings::System::kSymbolIndexFiles, symbol_index_files); |
| } |
| |
| if (!symbol_servers.empty()) { |
| system_settings.SetList(zxdb::ClientSettings::System::kSymbolServers, symbol_servers); |
| } |
| |
| if (!symbol_paths.empty()) { |
| system_settings.SetList(zxdb::ClientSettings::System::kSymbolPaths, symbol_paths); |
| } |
| |
| if (!build_id_dirs.empty()) { |
| system_settings.SetList(zxdb::ClientSettings::System::kBuildIdDirs, build_id_dirs); |
| } |
| |
| if (!ids_txts.empty()) { |
| system_settings.SetList(zxdb::ClientSettings::System::kIdsTxts, ids_txts); |
| } |
| |
| // 2) Ensure that the session correctly reads data off of the loop. |
| buffer_.set_data_available_callback([this]() { session_->OnStreamReadable(); }); |
| |
| // 3) Provide a loop, if none exists. |
| if (debug::MessageLoop::Current() == nullptr) { |
| std::string error_message; |
| bool success = loop_->Init(&error_message); |
| FX_CHECK(success) << error_message; |
| } |
| } |
| |
| void InterceptionWorkflow::AuthenticateServer(zxdb::SymbolServer* server) { |
| std::string key; |
| std::cout << "To authenticate " << server->name() |
| << ", please supply an authentication token. You can retrieve a token from:\n" |
| << server->AuthInfo() << '\n' |
| << "Enter the server authentication key: "; |
| std::cin >> key; |
| |
| // Do the authentication. |
| ++remaining_authentications_; |
| server->Authenticate(key, [this](const zxdb::Err& err) { |
| if (err.has_error()) { |
| FX_LOGS(ERROR) << "Server authentication failed: " << err.msg(); |
| server_authentication_error_ = true; |
| } |
| if (--remaining_authentications_ == 0) { |
| if (server_authentication_error_) { |
| Shutdown(); |
| } else { |
| FX_LOGS(INFO) << "Authentication successful"; |
| } |
| } |
| }); |
| } |
| |
| void InterceptionWorkflow::Connect(const std::string& host, uint16_t port, |
| const SimpleErrorFunction& and_then) { |
| zxdb::SessionConnectionInfo connect_info = {zxdb::SessionConnectionType::kNetwork, host, port}; |
| session_->Connect(connect_info, [and_then](const zxdb::Err& err) { and_then(err); }); |
| } |
| |
| void InterceptionWorkflow::UnixConnect(const std::string& unix_socket, |
| const SimpleErrorFunction& and_then) { |
| zxdb::SessionConnectionInfo connect_info = {zxdb::SessionConnectionType::kUnix, unix_socket, 0}; |
| session_->Connect(connect_info, [and_then](const zxdb::Err& err) { and_then(err); }); |
| } |
| |
| // Helper function that finds a target for fidlcat to attach itself to. The |
| // target with |process_koid| must already be running. |
| zxdb::Target* InterceptionWorkflow::GetTarget(zx_koid_t process_koid) { |
| for (zxdb::Target* target : session_->system().GetTargets()) { |
| if (target->GetProcess() && target->GetProcess()->GetKoid() == process_koid) { |
| return target; |
| } |
| } |
| return session_->system().CreateNewTarget(nullptr); |
| } |
| |
| zxdb::Target* InterceptionWorkflow::GetNewTarget() { |
| for (zxdb::Target* target : session_->system().GetTargets()) { |
| if (target->GetState() == zxdb::Target::State::kNone) { |
| return target; |
| } |
| } |
| return session_->system().CreateNewTarget(nullptr); |
| } |
| |
| bool InterceptionWorkflow::HasSymbolServers() const { |
| return !session_->system().GetSymbolServers().empty(); |
| } |
| |
| std::vector<zxdb::SymbolServer*> InterceptionWorkflow::GetSymbolServers() const { |
| return session_->system().GetSymbolServers(); |
| } |
| |
| void InterceptionWorkflow::Attach(const std::vector<zx_koid_t>& process_koids) { |
| for (zx_koid_t process_koid : process_koids) { |
| // Get a target for this process. |
| zxdb::Target* target = GetTarget(process_koid); |
| // If we are already attached, then we are done. |
| if (target->GetProcess()) { |
| FX_CHECK(target->GetProcess()->GetKoid() == process_koid) |
| << "Internal error: target attached to wrong process"; |
| continue; |
| } |
| |
| // The debugger is not yet attached to the process. Attach to it. |
| target->Attach( |
| process_koid, [this, target, process_koid](fxl::WeakPtr<zxdb::Target> /*target*/, |
| const zxdb::Err& err, uint64_t timestamp) { |
| if (!err.ok()) { |
| Process* process = syscall_decoder_dispatcher()->SearchProcess(process_koid); |
| if (process == nullptr) { |
| process = syscall_decoder_dispatcher()->CreateProcess("", process_koid, nullptr); |
| } |
| syscall_decoder_dispatcher()->AddProcessMonitoredEvent( |
| std::make_shared<ProcessMonitoredEvent>(timestamp, process, err.msg())); |
| return; |
| } |
| |
| SetBreakpoints(target->GetProcess(), timestamp); |
| }); |
| } |
| } |
| |
| void InterceptionWorkflow::AttachToJobs(const debug_ipc::ProcessTreeRecord& record, |
| const std::vector<std::uint64_t>& remote_job_id, |
| const std::vector<std::string>& remote_job_name, |
| const std::vector<std::string>& remote_name, |
| const std::vector<std::string>& extra_name) { |
| if (record.type == debug_ipc::ProcessTreeRecord::Type::kJob) { |
| bool attach_to_processes = false; |
| for (auto koid : remote_job_id) { |
| if (record.koid == koid) { |
| attach_to_processes = true; |
| break; |
| } |
| } |
| for (auto name : remote_job_name) { |
| if (record.name.find(name) != std::string::npos) { |
| attach_to_processes = true; |
| break; |
| } |
| } |
| if (attach_to_processes) { |
| zxdb::Job* job = session_->system().CreateNewJob(); |
| job->Attach(record.koid, [this, &remote_name, &extra_name](fxl::WeakPtr<zxdb::Job> weak_job, |
| const zxdb::Err& err) { |
| zxdb::Job* job = weak_job.get(); |
| if (err.ok() && (job != nullptr)) { |
| if (remote_name.empty()) { |
| filters_.push_back( |
| ProcessFilter{.filter = session_->system().CreateNewFilter(), .main_filter = true}); |
| filters_.back().filter->SetJob(job); |
| filters_.back().filter->SetPattern(zxdb::Filter::kAllProcessesPattern); |
| } else { |
| Filter(remote_name, /*main_filter=*/true, job); |
| Filter(extra_name, /*main_filter=*/false, job); |
| } |
| } |
| }); |
| return; |
| } |
| } |
| for (const auto& child : record.children) { |
| AttachToJobs(child, remote_job_id, remote_job_name, remote_name, extra_name); |
| } |
| } |
| |
| void InterceptionWorkflow::ProcessDetached(zx_koid_t koid, uint64_t timestamp) { |
| if (configured_processes_.find(koid) == configured_processes_.end()) { |
| return; |
| } |
| configured_processes_.erase(koid); |
| Process* process = syscall_decoder_dispatcher()->SearchProcess(koid); |
| if (process == nullptr) { |
| FX_LOGS(ERROR) << "Can't find process with koid=" << koid; |
| } else { |
| syscall_decoder_dispatcher()->AddStopMonitoringEvent( |
| std::make_shared<StopMonitoringEvent>(timestamp, process)); |
| } |
| Detach(); |
| } |
| |
| void InterceptionWorkflow::Detach() { |
| if (syscall_decoder_dispatcher()->decode_options().stay_alive) { |
| FX_LOGS(INFO) << "Waiting for more processes to monitor. Use Ctrl-C to exit fidlcat."; |
| return; |
| } |
| for (const auto& configured_process : configured_processes_) { |
| if (configured_process.second.main_process) { |
| // One main process is still running => don't shutdown fidlcat. |
| return; |
| } |
| } |
| if (!shutdown_done_) { |
| shutdown_done_ = true; |
| Shutdown(); |
| } |
| } |
| |
| void InterceptionWorkflow::Filter(const std::vector<std::string>& filter, bool main_filter, |
| zxdb::Job* job) { |
| if (filter.empty()) { |
| return; |
| } |
| |
| if (!main_filter) { |
| // We have an extra filter => wait for a main process to be started to start decoding events. |
| decode_events_ = false; |
| } |
| |
| for (const auto& pattern : filter) { |
| filters_.push_back( |
| ProcessFilter{.filter = session_->system().CreateNewFilter(), .main_filter = main_filter}); |
| if (job != nullptr) { |
| filters_.back().filter->SetJob(job); |
| } |
| filters_.back().filter->SetPattern(pattern); |
| } |
| } |
| |
| void InterceptionWorkflow::Launch(zxdb::Target* target, const std::vector<std::string>& command) { |
| FX_CHECK(!command.empty()) << "No arguments passed to launcher"; |
| |
| auto on_err = [this, command](const zxdb::Err& err) { |
| std::string cmd; |
| for (auto& param : command) { |
| cmd.append(param); |
| cmd.append(" "); |
| } |
| syscall_decoder_dispatcher()->AddProcessLaunchedEvent(std::make_shared<ProcessLaunchedEvent>( |
| debug_ipc::kTimestampDefault, cmd, err.ok() ? "" : err.msg())); |
| }; |
| |
| if (command[0] == "run") { |
| // The component workflow. |
| debug_ipc::LaunchRequest request; |
| request.inferior_type = debug_ipc::InferiorType::kComponent; |
| request.argv = std::vector<std::string>(command.begin() + 1, command.end()); |
| session_->remote_api()->Launch( |
| request, [this, target = target->GetWeakPtr(), on_err = std::move(on_err)]( |
| const zxdb::Err& err, debug_ipc::LaunchReply reply) { |
| if (err.ok() && (reply.status.has_error())) { |
| zxdb::Err status_err(zxdb::ErrType::kGeneral, reply.status.message()); |
| on_err(status_err); |
| } else { |
| on_err(err); |
| } |
| target->session()->ExpectComponent(reply.component_id); |
| if (target->GetProcess() != nullptr) { |
| SetBreakpoints(target->GetProcess(), reply.timestamp); |
| } |
| }); |
| return; |
| } |
| |
| target->SetArgs(command); |
| target->Launch([this, on_err = std::move(on_err)](fxl::WeakPtr<zxdb::Target> target, |
| const zxdb::Err& err, uint64_t timestamp) { |
| on_err(err); |
| if (target->GetProcess() != nullptr) { |
| SetBreakpoints(target->GetProcess(), debug_ipc::kTimestampDefault); |
| } |
| }); |
| } |
| |
| void InterceptionWorkflow::SetBreakpoints(zxdb::Process* process, uint64_t timestamp) { |
| if (configured_processes_.find(process->GetKoid()) != configured_processes_.end()) { |
| return; |
| } |
| |
| bool main_process = false; |
| for (const auto& filter : filters_) { |
| if (process->GetName().find(filter.filter->pattern()) != std::string::npos) { |
| main_process = filter.main_filter; |
| break; |
| } |
| } |
| |
| if (main_process) { |
| if (!decode_events_) { |
| // One main process has started => start decoding events. |
| decode_events_ = true; |
| |
| // Configure breakpoints for all the secondary processes already launched. |
| for (const auto& configured_process : configured_processes_) { |
| auto tmp = configured_process.second.process.get(); |
| if (tmp != nullptr) { |
| DoSetBreakpoints(tmp, timestamp); |
| } |
| } |
| } |
| } |
| |
| configured_processes_.emplace( |
| std::pair(process->GetKoid(), ConfiguredProcess(process->GetWeakPtr(), main_process))); |
| |
| if (decode_events_) { |
| DoSetBreakpoints(process, timestamp); |
| } |
| } |
| |
| void InterceptionWorkflow::DoSetBreakpoints(zxdb::Process* zxdb_process, uint64_t timestamp) { |
| Process* process = syscall_decoder_dispatcher()->SearchProcess(zxdb_process->GetKoid()); |
| if (process == nullptr) { |
| process = syscall_decoder_dispatcher()->CreateProcess( |
| zxdb_process->GetName(), zxdb_process->GetKoid(), zxdb_process->GetWeakPtr()); |
| } |
| syscall_decoder_dispatcher()->AddProcessMonitoredEvent( |
| std::make_shared<ProcessMonitoredEvent>(timestamp, process, "")); |
| |
| for (auto& syscall : syscall_decoder_dispatcher()->syscalls()) { |
| bool put_breakpoint = true; |
| if (!syscall.second->is_function()) { |
| // Only apply the filters to syscalls. We always want to intercept regular |
| // functions because they give us the information about the starting handles. |
| if (!syscall_decoder_dispatcher()->decode_options().syscall_filters.empty()) { |
| put_breakpoint = false; |
| for (const auto& syscall_filter : |
| syscall_decoder_dispatcher()->decode_options().syscall_filters) { |
| if (re2::RE2::FullMatch(syscall.second->name(), *syscall_filter)) { |
| put_breakpoint = true; |
| break; |
| } |
| } |
| } |
| if (put_breakpoint) { |
| for (const auto& syscall_filter : |
| syscall_decoder_dispatcher()->decode_options().exclude_syscall_filters) { |
| if (re2::RE2::FullMatch(syscall.second->name(), *syscall_filter)) { |
| put_breakpoint = false; |
| break; |
| } |
| } |
| } |
| } |
| if (put_breakpoint) { |
| zxdb::BreakpointSettings settings; |
| settings.enabled = true; |
| settings.name = syscall.second->name(); |
| settings.stop_mode = zxdb::BreakpointSettings::StopMode::kThread; |
| settings.type = debug_ipc::BreakpointType::kSoftware; |
| settings.scope = zxdb::ExecutionScope(zxdb_process->GetTarget()); |
| |
| syscall.second->ComputeAutomation(session()->arch()); |
| |
| settings.instructions = syscall.second->invoked_bp_instructions(); |
| if (settings.instructions.size() > 0) { |
| settings.has_automation = true; |
| } |
| |
| zxdb::Identifier identifier; |
| zxdb::Err err = |
| zxdb::ExprParser::ParseIdentifier(syscall.second->breakpoint_name(), &identifier); |
| FX_CHECK(err.ok()); |
| settings.locations.emplace_back(std::move(identifier)); |
| |
| zxdb::Breakpoint* breakpoint = session_->system().CreateNewBreakpoint(); |
| breakpoint->SetSettings(settings); |
| } |
| } |
| } |
| |
| void InterceptionWorkflow::Go() { |
| debug::MessageLoop* current = debug::MessageLoop::Current(); |
| current->Run(); |
| current->Cleanup(); |
| } |
| |
| namespace { |
| |
| // Makes sure we never get stuck in the workflow at a breakpoint. |
| class AlwaysContinue { |
| public: |
| explicit AlwaysContinue(zxdb::Thread* thread) : thread_(thread) {} |
| ~AlwaysContinue() { thread_->Continue(false); } |
| |
| private: |
| zxdb::Thread* thread_; |
| }; |
| |
| } // namespace |
| |
| } // namespace fidlcat |