From 2fda25862c2326f75f8d6ef1bddf346f51c2bc58 Mon Sep 17 00:00:00 2001 From: Myoungho Shin Date: Thu, 20 Aug 2026 09:47:53 -0700 Subject: [PATCH] Harden correlated source capture --- CMakeLists.txt | 1 + daemon/launcher/cli_parse.hpp | 1 + daemon/launcher/cli_trace_options.cpp | 5 + daemon/launcher/trace_command_common.cpp | 25 ++ include/gpufl/core/dictionary_manager.cpp | 99 +++++- include/gpufl/core/dictionary_manager.hpp | 18 +- include/gpufl/core/env_vars.hpp | 4 + include/gpufl/core/monitor.cpp | 20 +- include/gpufl/core/monitor.hpp | 2 + include/gpufl/core/monitor_batch_manager.cpp | 10 +- include/gpufl/core/monitor_batch_manager.hpp | 4 +- include/gpufl/core/monitor_configuration.cpp | 1 + include/gpufl/core/source_capture_policy.cpp | 355 +++++++++++++++++++ include/gpufl/core/source_capture_policy.hpp | 119 +++++++ include/gpufl/core/startup_configuration.cpp | 13 + include/gpufl/gpufl.hpp | 4 + tests/CMakeLists.txt | 1 + tests/core/test_monitor_configuration.cpp | 5 + tests/core/test_source_capture_policy.cpp | 264 ++++++++++++++ tests/core/test_startup_configuration.cpp | 18 + tests/launcher/test_cli_parse.cpp | 6 + tests/launcher/test_trace_run_plan.cpp | 6 + 22 files changed, 948 insertions(+), 33 deletions(-) create mode 100644 include/gpufl/core/source_capture_policy.cpp create mode 100644 include/gpufl/core/source_capture_policy.hpp create mode 100644 tests/core/test_source_capture_policy.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 444b5c1..9d4c4ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -163,6 +163,7 @@ target_sources(gpufl PRIVATE include/gpufl/backends/amd/amd_capture_capabilities.cpp include/gpufl/backends/amd/amd_trace_policy.cpp include/gpufl/core/dictionary_manager.cpp + include/gpufl/core/source_capture_policy.cpp include/gpufl/core/sass_compressor.cpp include/gpufl/core/logger/logger.cpp include/gpufl/core/logger/lifecycle_control_journal.cpp diff --git a/daemon/launcher/cli_parse.hpp b/daemon/launcher/cli_parse.hpp index b289169..b856c75 100644 --- a/daemon/launcher/cli_parse.hpp +++ b/daemon/launcher/cli_parse.hpp @@ -22,6 +22,7 @@ struct TraceArgs { bool verbose = false; // -v bool quiet = false; // -q bool no_source = false; // --no-source: explicit source-content opt-out + std::string source_root; // --source-root: approved project source boundary bool upload = false; // --upload: start gpufl-agent for live upload std::string backend_url; // --backend-url; else GPUFL_BACKEND_URL std::string api_key; // --api-key; else GPUFL_API_KEY diff --git a/daemon/launcher/cli_trace_options.cpp b/daemon/launcher/cli_trace_options.cpp index e295db8..666454a 100644 --- a/daemon/launcher/cli_trace_options.cpp +++ b/daemon/launcher/cli_trace_options.cpp @@ -448,6 +448,11 @@ const CliOptionManager& traceOptions() { "correlation", kSection(TraceHelpSection::Capture), &setFlag<&TraceArgs::no_source>) + .add({"--source-root"}, "", + "Allow correlated CUDA/C++ source only within this project " + "directory (default: current working directory)", + kSection(TraceHelpSection::Capture), + &parseNonEmptyStringOption<&TraceArgs::source_root>) // Runtime .add({"-q", "--quiet"}, "", diff --git a/daemon/launcher/trace_command_common.cpp b/daemon/launcher/trace_command_common.cpp index be2318d..36b8531 100644 --- a/daemon/launcher/trace_command_common.cpp +++ b/daemon/launcher/trace_command_common.cpp @@ -40,6 +40,24 @@ fs::path findInjectLib(const TracePlatform& platform, const fs::path& exe) { return {}; } +fs::path resolveSourceRoot(const TraceArgs& args, std::string& error) { + std::error_code ec; + fs::path root = args.source_root.empty() + ? fs::current_path(ec) + : fs::path(args.source_root); + if (ec) { + error = "cannot resolve the current working directory for source " + "capture: " + ec.message(); + return {}; + } + root = fs::weakly_canonical(root, ec); + if (ec || !fs::is_directory(root, ec) || ec) { + error = "--source-root is not an accessible directory"; + return {}; + } + return root; +} + // A '+'-joined pass token ("Trace+PcSampling") runs those engines together in // one process via GPUFL_ENGINE_COMBO. Returns the comma-joined combo for a // composite token, or "" for a single-engine token. @@ -736,6 +754,11 @@ int runTraceCommon(const TraceArgs& args, const TracePlatform& platform) { DebugLogger::setEnabled(args.verbose); std::string error; + const fs::path source_root = resolveSourceRoot(args, error); + if (source_root.empty()) { + std::fprintf(stderr, "gpufl: %s\n", error.c_str()); + return 2; + } if (!platform.prepareInjectionEnv(inject_lib, error)) { std::fprintf(stderr, "gpufl: %s\n", error.c_str()); return 2; @@ -749,6 +772,8 @@ int runTraceCommon(const TraceArgs& args, const TracePlatform& platform) { !setEnvOrPrint(platform, env::kInjectProfile, inject::kProfileComprehensive) || !setEnvOrPrint(platform, env::kIncludeSource, args.no_source ? "0" : "1") || + !setEnvOrPrint(platform, env::kSourceRoot, + source_root.string()) || !setEnvOrPrint(platform, env::kInjectUpload, "0")) { return 2; } diff --git a/include/gpufl/core/dictionary_manager.cpp b/include/gpufl/core/dictionary_manager.cpp index 2362307..4cedbee 100644 --- a/include/gpufl/core/dictionary_manager.cpp +++ b/include/gpufl/core/dictionary_manager.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -75,6 +76,45 @@ void appendDict(std::ostringstream& oss, const char* key, oss << '}'; } +std::string buildSourceCaptureManifestJson( + const detail::SourceCaptureManifest& manifest, + const std::string& session_id) { + std::ostringstream oss; + oss << "{\"version\":1,\"type\":\"source_capture_manifest\"" + << ",\"session_id\":\"" << model::jsonEscape(session_id) << '"' + << ",\"policy\":\"correlated-project-source-v1\"" + << ",\"enabled\":" << (manifest.enabled ? "true" : "false") + << ",\"approved_root_count\":" << manifest.approved_root_count + << ",\"limits\":{\"max_files\":" << manifest.limits.max_files + << ",\"max_bytes_per_file\":" + << manifest.limits.max_bytes_per_file + << ",\"max_total_bytes\":" << manifest.limits.max_total_bytes + << ",\"max_line_bytes\":" << manifest.limits.max_line_bytes + << ",\"max_manifest_entries\":" + << manifest.limits.max_manifest_entries << "},\"files\":["; + bool first = true; + for (const auto& file : manifest.files) { + if (!first) oss << ','; + first = false; + oss << "{\"source_file_id\":" << file.source_file_id + << ",\"logical_path\":\"" + << model::jsonEscape(file.logical_path) << '"' + << ",\"discovery_reason\":\"" + << model::jsonEscape(file.discovery_reason) << '"' + << ",\"disposition\":\"" + << detail::sourceCaptureDispositionName(file.disposition) << '"' + << ",\"bytes\":" << file.bytes << '}'; + } + oss << "],\"totals\":{\"captured_files\":" + << manifest.captured_files + << ",\"captured_bytes\":" << manifest.captured_bytes + << ",\"skipped_files\":" << manifest.skipped_files + << ",\"truncated_files\":0" + << ",\"omitted_manifest_entries\":" + << manifest.omitted_manifest_entries << "}}"; + return oss.str(); +} + #ifndef _WIN32 // Launch argv[0] with stdout connected to a pipe we read and stderr sent // to /dev/null, via posix_spawn - deliberately NOT popen/system. @@ -123,6 +163,12 @@ FILE *spawnReadPipe(char *const argv[], pid_t &outPid) { } // namespace +void DictionaryManager::configureSourceCapture( + const bool enabled, const SourceCaptureSettings& settings) { + std::lock_guard lk(mu_); + source_capture_.configure(enabled, settings); +} + uint32_t DictionaryManager::internSourceFile(const std::string& path) { if (path.empty()) return 0; std::lock_guard lk(mu_); @@ -131,32 +177,39 @@ uint32_t DictionaryManager::internSourceFile(const std::string& path) { return it->second; const uint32_t id = next_source_file_id_++; source_file_dict_[path] = id; - dirty_source_files_[path] = id; - - // Read file content eagerly when source collection is enabled. - // When disabled, we still intern the path (needed for function keys - // and source_file_id in profile samples) but skip reading the actual - // source code from disk - users who don't want their source code - // sent to the backend can set enable_source_collection = false. - if (enable_source_collection) { - std::ifstream f(path); - if (f.is_open()) { - std::vector lines; - std::string line; - while (std::getline(f, line)) lines.push_back(line); - if (!lines.empty()) pending_source_content_[id] = std::move(lines); - } + auto capture = source_capture_.capture( + path, id, "profiler_source_correlation"); + source_file_names_[id] = capture.record.logical_path; + dirty_source_files_[capture.record.logical_path] = id; + if (capture.record.disposition == + detail::SourceCaptureDisposition::Captured) { + pending_source_content_[id] = std::move(capture.lines); } return id; } +std::string DictionaryManager::sourceFileName( + const uint32_t source_file_id) { + std::lock_guard lk(mu_); + const auto it = source_file_names_.find(source_file_id); + return it == source_file_names_.end() ? std::string() : it->second; +} + void DictionaryManager::flushSourceContent(Logger& logger, const std::string& session_id) { std::unordered_map> pending; + std::optional manifest; { std::lock_guard lk(mu_); - if (pending_source_content_.empty()) return; pending = std::move(pending_source_content_); + if (source_capture_.manifestDirty()) { + manifest = source_capture_.manifest(); + source_capture_.markManifestFlushed(); + } + } + if (manifest) { + logger.write(SassLine{ + buildSourceCaptureManifestJson(*manifest, session_id)}); } for (auto& [file_id, lines] : pending) { std::ostringstream oss; @@ -732,8 +785,12 @@ void DictionaryManager::flushDisassembly(Logger& logger, // Demangle the name so it merges with the PC-sample function // entry (which is demangled), and carry the mangled symbol so // the funcKey/disassembly join still keys off md5(symbol). + const auto source_id = sourceFileIds.find(primarySourceFile); + const std::string source_name = source_id == sourceFileIds.end() + ? std::string() + : sourceFileName(source_id->second); internFunction( - core::DemangleFunctionKey(funcName + "@" + primarySourceFile), + core::DemangleFunctionKey(funcName + "@" + source_name), funcName); } @@ -781,8 +838,12 @@ void DictionaryManager::flushDisassembly(Logger& logger, bestCount = cnt; } } + const auto source_id = sourceFileIds.find(primarySourceFile); + const std::string source_name = source_id == sourceFileIds.end() + ? std::string() + : sourceFileName(source_id->second); const std::string funcKey = - core::DemangleFunctionKey(funcName + "@" + primarySourceFile); + core::DemangleFunctionKey(funcName + "@" + source_name); const uint32_t functionId = internFunction(funcKey, funcName); // Build profile_sample_batch JSON @@ -901,7 +962,7 @@ void DictionaryManager::flushDictionaryForSegment( for (const auto& [name, id] : metric_dict_) { if (emitter.metrics_.insert(id).second) dm.emplace(name, id); } - for (const auto& [name, id] : source_file_dict_) { + for (const auto& [id, name] : source_file_names_) { if (emitter.source_files_.insert(id).second) dsf.emplace(name, id); } } diff --git a/include/gpufl/core/dictionary_manager.hpp b/include/gpufl/core/dictionary_manager.hpp index 5cfc619..6baebc3 100644 --- a/include/gpufl/core/dictionary_manager.hpp +++ b/include/gpufl/core/dictionary_manager.hpp @@ -7,6 +7,8 @@ #include #include +#include "gpufl/core/source_capture_policy.hpp" + namespace gpufl { class Logger; @@ -38,11 +40,10 @@ class SegmentDictionaryEmitter { class DictionaryManager { public: - /// When false, internSourceFile() still interns the path (needed for - /// the function key) but does NOT read the file content from disk. - /// flushSourceContent() becomes a no-op. Users who don't want their - /// source code sent to the backend can set this to false. - bool enable_source_collection = true; + /// Configure the root/type/file-kind/budget policy used before any + /// profiler-discovered path is opened. + void configureSourceCapture(bool enabled, + const SourceCaptureSettings& settings); uint32_t internKernel(const std::string& name) { std::lock_guard lk(mu_); @@ -97,6 +98,7 @@ class DictionaryManager { } uint32_t internSourceFile(const std::string& path); + std::string sourceFileName(uint32_t source_file_id); // Emits a dictionary_update JSON line to Channel::All for any new entries // accumulated since the last call. No-op if nothing is dirty. @@ -148,9 +150,11 @@ class DictionaryManager { dirty_metrics_.clear(); next_metric_id_ = 1; source_file_dict_.clear(); + source_file_names_.clear(); dirty_source_files_.clear(); next_source_file_id_ = 1; pending_source_content_.clear(); + source_capture_.reset(); pending_disasm_cubins_.clear(); } @@ -177,11 +181,15 @@ class DictionaryManager { uint32_t next_metric_id_ = 1; std::unordered_map source_file_dict_; + // file_id -> normalized logical path emitted to product-facing logs. + // source_file_dict_ keeps the local lookup key but is never serialized. + std::unordered_map source_file_names_; std::unordered_map dirty_source_files_; uint32_t next_source_file_id_ = 1; // file_id → lines (populated once per new source file, flushed via flushSourceContent) std::unordered_map> pending_source_content_; + detail::SourceCapturePolicy source_capture_; // cubin_crc → raw bytes (populated once per cubin, flushed via flushDisassembly) std::unordered_map> pending_disasm_cubins_; diff --git a/include/gpufl/core/env_vars.hpp b/include/gpufl/core/env_vars.hpp index b37d21f..2fd6d05 100644 --- a/include/gpufl/core/env_vars.hpp +++ b/include/gpufl/core/env_vars.hpp @@ -86,6 +86,10 @@ constexpr const char* kDebugOutput = "GPUFL_DEBUG"; // explicit value: "1" by default, or "0" for --no-source. Applying it after // file configuration makes the command-line privacy choice authoritative. constexpr const char* kIncludeSource = "GPUFL_INCLUDE_SOURCE"; +// Launcher-owned approved project root for bounded correlated source capture. +// This is local process configuration only; it must not be serialized into +// product events or broad logs. +constexpr const char* kSourceRoot = "GPUFL_SOURCE_ROOT"; // Override the per-file log rotation threshold in bytes (default: // Logger::kDefaultRotateBytes = 64 MiB). Mainly for tests - a tiny value diff --git a/include/gpufl/core/monitor.cpp b/include/gpufl/core/monitor.cpp index 27bd6ea..5a1dff3 100644 --- a/include/gpufl/core/monitor.cpp +++ b/include/gpufl/core/monitor.cpp @@ -387,13 +387,15 @@ struct RecordProcessor { static void handlePcSample(const ActivityRecord& rec, Runtime* rt) { uint8_t kind = rec.metric_name[0] != '\0' || (rec.sample_kind[0] != '\0' && rec.sample_kind[0] == 's') ? 1 : 0; - const std::string func_key = std::string(rec.function_name) + "@" + rec.source_file; + const uint32_t source_file_id = + g_state.batches.internSourceFile(rec.source_file); + const std::string func_key = std::string(rec.function_name) + "@" + + g_state.batches.sourceFileName(source_file_id); const uint32_t function_id = g_state.batches.internFunction( g_state.metadata.demangleFunctionKey(func_key), std::string(rec.function_name)); const std::string metric_key = (rec.metric_name[0] != '\0') ? std::string(rec.metric_name) : rec.reason_name; const uint32_t metric_id = g_state.batches.internMetric(metric_key); const uint32_t scope_name_id = g_state.batches.activeScopeNameId(); - const uint32_t source_file_id = g_state.batches.internSourceFile(rec.source_file); const ProfileSampleBatchRow row = detail::MakeProfileSampleBatchRow( rec, kind, function_id, metric_id, scope_name_id, source_file_id); @@ -576,7 +578,8 @@ void Monitor::Initialize(const MonitorOptions& opts) { detail::ActiveCounterProvider()->begin_session(); g_state.batches.reset(); g_state.metadata.reset(); - g_state.batches.setSourceCollectionEnabled(opts.enable_source_collection); + g_state.batches.configureSourceCapture(opts.enable_source_collection, + opts.source_capture); if (Runtime* rt = runtime(); rt && rt->hasSegmentContext()) { g_state.batches.bindFlushRuntime(rt); } @@ -822,15 +825,20 @@ void Monitor::PushProfileSamples(const std::vector& samples) for (const auto& s : samples) { ProfileSampleBatchRow row; row.ts_ns = s.ts_ns; row.corr_id = s.corr_id; row.device_id = s.device_id; - const std::string funcSymbol = s.function_key.substr(0, s.function_key.find('@')); - row.function_id = g_state.batches.internFunction(demangledKey(s.function_key), funcSymbol); + const std::string funcSymbol = + s.function_key.substr(0, s.function_key.find('@')); + row.source_file_id = + g_state.batches.internSourceFile(s.source_file); + const std::string normalizedFunctionKey = funcSymbol + "@" + + g_state.batches.sourceFileName(row.source_file_id); + row.function_id = g_state.batches.internFunction( + demangledKey(normalizedFunctionKey), funcSymbol); row.pc_offset = s.pc_offset; row.metric_id = g_state.batches.internMetric(s.metric_name); row.metric_value = s.metric_value; row.stall_reason = s.stall_reason; row.sample_kind = s.sample_kind; row.scope_name_id = scope_name_id; - row.source_file_id = g_state.batches.internSourceFile(s.source_file); row.source_line = s.source_line; rows.push_back(row); } diff --git a/include/gpufl/core/monitor.hpp b/include/gpufl/core/monitor.hpp index 877e9d6..5016177 100644 --- a/include/gpufl/core/monitor.hpp +++ b/include/gpufl/core/monitor.hpp @@ -9,6 +9,7 @@ #include "gpufl/core/activity_record.hpp" #include "gpufl/core/events.hpp" #include "gpufl/core/ring_buffer.hpp" +#include "gpufl/core/source_capture_policy.hpp" #include "gpufl/core/stream_handle.hpp" #include "gpufl/core/trace_type.hpp" @@ -157,6 +158,7 @@ struct MonitorOptions { bool enable_debug_output = false; bool enable_stack_trace = false; bool enable_source_collection = true; + SourceCaptureSettings source_capture; // Gate for CUPTI_ACTIVITY_KIND_EXTERNAL_CORRELATION. Mirror of // InitOptions::enable_external_correlation; copied across in the // gpufl::init() → CuptiBackend::initialize() conversion path. diff --git a/include/gpufl/core/monitor_batch_manager.cpp b/include/gpufl/core/monitor_batch_manager.cpp index d0c9260..973ba21 100644 --- a/include/gpufl/core/monitor_batch_manager.cpp +++ b/include/gpufl/core/monitor_batch_manager.cpp @@ -63,8 +63,9 @@ void MonitorBatchManager::clearFlushSink() { flushSink_ = {}; } -void MonitorBatchManager::setSourceCollectionEnabled(bool enabled) { - dictManager_.enable_source_collection = enabled; +void MonitorBatchManager::configureSourceCapture( + const bool enabled, const SourceCaptureSettings& settings) { + dictManager_.configureSourceCapture(enabled, settings); } void MonitorBatchManager::flushAll(FlushMode mode) { @@ -200,6 +201,11 @@ uint32_t MonitorBatchManager::internSourceFile(const std::string& path) { return dictManager_.internSourceFile(path); } +std::string MonitorBatchManager::sourceFileName( + const uint32_t source_file_id) { + return dictManager_.sourceFileName(source_file_id); +} + void MonitorBatchManager::enqueueDisassembly(uint64_t crc, const uint8_t* data, size_t size) { dictManager_.enqueueDisassembly(crc, data, size); } diff --git a/include/gpufl/core/monitor_batch_manager.hpp b/include/gpufl/core/monitor_batch_manager.hpp index 27279ae..3762205 100644 --- a/include/gpufl/core/monitor_batch_manager.hpp +++ b/include/gpufl/core/monitor_batch_manager.hpp @@ -30,7 +30,8 @@ class MonitorBatchManager { void reset(); void bindFlushRuntime(Runtime* runtime); void clearFlushSink(); - void setSourceCollectionEnabled(bool enabled); + void configureSourceCapture(bool enabled, + const SourceCaptureSettings& settings); void flushAll(FlushMode mode = FlushMode::Fast); void flushDictionarySnapshot(SegmentDictionaryEmitter& emitter, Logger& logger, @@ -42,6 +43,7 @@ class MonitorBatchManager { const std::string& func_symbol = std::string()); uint32_t internMetric(const std::string& name); uint32_t internSourceFile(const std::string& path); + std::string sourceFileName(uint32_t source_file_id); void enqueueDisassembly(uint64_t crc, const uint8_t* data, size_t size); void flushDisassembly(); diff --git a/include/gpufl/core/monitor_configuration.cpp b/include/gpufl/core/monitor_configuration.cpp index 223ab33..1bafe4a 100644 --- a/include/gpufl/core/monitor_configuration.cpp +++ b/include/gpufl/core/monitor_configuration.cpp @@ -110,6 +110,7 @@ MonitorOptions buildMonitorOptions(const InitOptions& options) { monitor_options.kernel_sample_rate_ms = options.kernel_sample_rate_ms; monitor_options.enable_stack_trace = options.enable_stack_trace; monitor_options.enable_source_collection = options.enable_source_collection; + monitor_options.source_capture = options.source_capture; monitor_options.enable_external_correlation = options.enable_external_correlation; monitor_options.enable_synchronization = options.enable_synchronization; diff --git a/include/gpufl/core/source_capture_policy.cpp b/include/gpufl/core/source_capture_policy.cpp new file mode 100644 index 0000000..da94341 --- /dev/null +++ b/include/gpufl/core/source_capture_policy.cpp @@ -0,0 +1,355 @@ +#include "gpufl/core/source_capture_policy.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace gpufl::detail { +namespace { + +namespace fs = std::filesystem; + +std::string lowerAscii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](const unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return value; +} + +bool componentEquals(const fs::path& left, const fs::path& right) { +#ifdef _WIN32 + return lowerAscii(left.string()) == lowerAscii(right.string()); +#else + return left == right; +#endif +} + +bool isWithin(const fs::path& candidate, const fs::path& root) { + auto candidate_it = candidate.begin(); + for (auto root_it = root.begin(); root_it != root.end(); + ++root_it, ++candidate_it) { + if (candidate_it == candidate.end() || + !componentEquals(*candidate_it, *root_it)) { + return false; + } + } + return true; +} + +std::vector existingCanonicalRoots( + const std::vector& roots) { + std::vector result; + for (const auto& value : roots) { + if (value.empty()) continue; + std::error_code ec; + fs::path canonical = fs::weakly_canonical(fs::path(value), ec); + if (ec || !fs::is_directory(canonical, ec) || ec) continue; + if (std::none_of(result.begin(), result.end(), + [&](const fs::path& existing) { + return isWithin(canonical, existing) && + isWithin(existing, canonical); + })) { + result.push_back(std::move(canonical)); + } + } + return result; +} + +void appendEnvironmentRoot(std::vector& roots, const char* key) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return; + std::error_code ec; + fs::path canonical = fs::weakly_canonical(fs::path(raw), ec); + if (!ec && fs::is_directory(canonical, ec) && !ec) { + roots.push_back(std::move(canonical)); + } +} + +void appendEnvironmentChildRoot(std::vector& roots, + const char* key, + const fs::path& child) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return; + std::error_code ec; + fs::path canonical = fs::weakly_canonical(fs::path(raw) / child, ec); + if (!ec && fs::is_directory(canonical, ec) && !ec) { + roots.push_back(std::move(canonical)); + } +} + +std::vector excludedSystemRoots() { + std::vector roots; +#ifdef _WIN32 + appendEnvironmentRoot(roots, "CUDA_PATH"); + appendEnvironmentChildRoot(roots, "ProgramFiles", + "NVIDIA GPU Computing Toolkit"); + appendEnvironmentChildRoot(roots, "ProgramFiles(x86)", + "NVIDIA GPU Computing Toolkit"); + appendEnvironmentRoot(roots, "SystemRoot"); +#else + const std::vector defaults = { + "/usr/include", "/usr/local/include", "/usr/local/cuda", + "/opt/cuda", "/opt/rocm"}; + roots = existingCanonicalRoots(defaults); + appendEnvironmentRoot(roots, "CUDA_PATH"); +#endif + return roots; +} + +bool hasAllowedExtension(const fs::path& path) { + static constexpr std::array extensions = { + ".c", ".cc", ".cpp", ".cxx", ".cu", ".cuh", ".h", + ".hh", ".hpp", ".hxx", ".inc", ".inl", ".tpp"}; + const std::string extension = lowerAscii(path.extension().string()); + return std::any_of(extensions.begin(), extensions.end(), + [&](const char* allowed) { + return extension == allowed; + }); +} + +std::string logicalPathFor(const fs::path& canonical, + const fs::path& root, + const std::size_t root_index, + const std::size_t root_count) { + std::error_code ec; + fs::path relative = fs::relative(canonical, root, ec); + if (ec || relative.empty()) relative = canonical.filename(); + const std::string logical = relative.generic_string(); + if (root_count <= 1) return logical; + return "root-" + std::to_string(root_index) + "/" + logical; +} + +bool splitLines(const std::string& content, const std::size_t max_line_bytes, + std::vector& lines) { + std::size_t line_start = 0; + while (line_start < content.size()) { + const std::size_t newline = content.find('\n', line_start); + const std::size_t line_end = + newline == std::string::npos ? content.size() : newline; + std::size_t length = line_end - line_start; + if (length > 0 && content[line_end - 1] == '\r') --length; + if (length > max_line_bytes) return false; + lines.emplace_back(content.substr(line_start, length)); + if (newline == std::string::npos) break; + line_start = newline + 1; + } + return true; +} + +} // namespace + +const char* sourceCaptureDispositionName( + const SourceCaptureDisposition value) { + switch (value) { + case SourceCaptureDisposition::Captured: + return "captured"; + case SourceCaptureDisposition::Disabled: + return "capture_disabled"; + case SourceCaptureDisposition::InvalidPath: + return "invalid_path"; + case SourceCaptureDisposition::NoApprovedRoot: + return "no_approved_root"; + case SourceCaptureDisposition::OutsideApprovedRoots: + return "outside_approved_roots"; + case SourceCaptureDisposition::SymlinkEscape: + return "symlink_escape"; + case SourceCaptureDisposition::ExcludedSystemRoot: + return "excluded_system_root"; + case SourceCaptureDisposition::UnsupportedExtension: + return "unsupported_extension"; + case SourceCaptureDisposition::NonTextContent: + return "non_text_content"; + case SourceCaptureDisposition::NotRegularFile: + return "not_regular_file"; + case SourceCaptureDisposition::FileLimitExceeded: + return "file_limit_exceeded"; + case SourceCaptureDisposition::FileTooLarge: + return "file_too_large"; + case SourceCaptureDisposition::TotalBudgetExceeded: + return "total_budget_exceeded"; + case SourceCaptureDisposition::LineTooLong: + return "line_too_long"; + case SourceCaptureDisposition::ReadFailed: + return "read_failed"; + case SourceCaptureDisposition::ChangedDuringRead: + return "changed_during_read"; + } + return "invalid_path"; +} + +void SourceCapturePolicy::configure( + const bool enabled, const SourceCaptureSettings& settings) { + enabled_ = enabled; + settings_ = settings; + approved_roots_ = existingCanonicalRoots(settings.approved_roots); + manifest_ = {}; + manifest_.enabled = enabled_; + manifest_.approved_root_count = approved_roots_.size(); + manifest_.limits = settings_.limits; + manifest_dirty_ = true; +} + +void SourceCapturePolicy::reset() { + enabled_ = false; + settings_ = {}; + approved_roots_.clear(); + manifest_ = {}; + manifest_dirty_ = false; +} + +std::string SourceCapturePolicy::unavailableLogicalPath( + const fs::path& discovered_path, + const std::uint32_t source_file_id) const { + std::string filename = discovered_path.filename().generic_string(); + if (filename.empty()) filename = "unknown"; + return "unavailable/source-" + std::to_string(source_file_id) + "/" + + filename; +} + +void SourceCapturePolicy::record(SourceCaptureRecord value) { + if (value.disposition == SourceCaptureDisposition::Captured) { + ++manifest_.captured_files; + manifest_.captured_bytes += value.bytes; + } else { + ++manifest_.skipped_files; + } + + if (manifest_.files.size() < settings_.limits.max_manifest_entries) { + manifest_.files.push_back(std::move(value)); + } else { + ++manifest_.omitted_manifest_entries; + } + manifest_dirty_ = true; +} + +SourceCaptureResult SourceCapturePolicy::capture( + const std::string& discovered_path, + const std::uint32_t source_file_id, + const std::string& discovery_reason) { + SourceCaptureResult result; + result.record.source_file_id = source_file_id; + result.record.discovery_reason = discovery_reason; + const fs::path input(discovered_path); + result.record.logical_path = + unavailableLogicalPath(input, source_file_id); + + const auto reject = [&](const SourceCaptureDisposition disposition, + const std::uint64_t bytes = 0) { + result.record.disposition = disposition; + result.record.bytes = bytes; + record(result.record); + return result; + }; + + if (!enabled_) return reject(SourceCaptureDisposition::Disabled); + if (discovered_path.empty()) { + return reject(SourceCaptureDisposition::InvalidPath); + } + if (approved_roots_.empty()) { + return reject(SourceCaptureDisposition::NoApprovedRoot); + } + + std::error_code ec; + const fs::path absolute = fs::absolute(input, ec).lexically_normal(); + if (ec) return reject(SourceCaptureDisposition::InvalidPath); + const fs::path canonical = fs::weakly_canonical(absolute, ec); + if (ec) return reject(SourceCaptureDisposition::InvalidPath); + + std::size_t lexical_root = approved_roots_.size(); + std::size_t canonical_root = approved_roots_.size(); + for (std::size_t i = 0; i < approved_roots_.size(); ++i) { + if (lexical_root == approved_roots_.size() && + isWithin(absolute, approved_roots_[i])) { + lexical_root = i; + } + if (canonical_root == approved_roots_.size() && + isWithin(canonical, approved_roots_[i])) { + canonical_root = i; + } + } + if (canonical_root == approved_roots_.size()) { + return reject(lexical_root != approved_roots_.size() + ? SourceCaptureDisposition::SymlinkEscape + : SourceCaptureDisposition::OutsideApprovedRoots); + } + + result.record.logical_path = logicalPathFor( + canonical, approved_roots_[canonical_root], canonical_root, + approved_roots_.size()); + + for (const auto& excluded : excludedSystemRoots()) { + if (isWithin(canonical, excluded)) { + return reject(SourceCaptureDisposition::ExcludedSystemRoot); + } + } + if (!hasAllowedExtension(canonical)) { + return reject(SourceCaptureDisposition::UnsupportedExtension); + } + const fs::file_status status = fs::status(canonical, ec); + if (ec || !fs::is_regular_file(status)) { + return reject(SourceCaptureDisposition::NotRegularFile); + } + if (manifest_.captured_files >= settings_.limits.max_files) { + return reject(SourceCaptureDisposition::FileLimitExceeded); + } + + const std::uintmax_t size = fs::file_size(canonical, ec); + if (ec || size > (std::numeric_limits::max)()) { + return reject(SourceCaptureDisposition::ReadFailed); + } + const std::uint64_t bytes = static_cast(size); + if (bytes > settings_.limits.max_bytes_per_file) { + return reject(SourceCaptureDisposition::FileTooLarge, bytes); + } + if (manifest_.captured_bytes > settings_.limits.max_total_bytes || + bytes > settings_.limits.max_total_bytes - manifest_.captured_bytes) { + return reject(SourceCaptureDisposition::TotalBudgetExceeded, bytes); + } + + std::ifstream stream(canonical, std::ios::binary); + if (!stream.is_open()) { + return reject(SourceCaptureDisposition::ReadFailed, bytes); + } + std::string content; + content.reserve(static_cast(bytes)); + std::array buffer{}; + while (stream) { + stream.read(buffer.data(), static_cast(buffer.size())); + const std::streamsize count = stream.gcount(); + if (count <= 0) break; + const auto chunk = static_cast(count); + if (content.size() > settings_.limits.max_bytes_per_file || + chunk > settings_.limits.max_bytes_per_file - content.size()) { + return reject(SourceCaptureDisposition::ChangedDuringRead, + content.size() + chunk); + } + content.append(buffer.data(), static_cast(count)); + } + if (stream.bad()) { + return reject(SourceCaptureDisposition::ReadFailed, + content.size()); + } + if (content.size() != bytes) { + return reject(SourceCaptureDisposition::ChangedDuringRead, + content.size()); + } + if (content.find('\0') != std::string::npos) { + return reject(SourceCaptureDisposition::NonTextContent, bytes); + } + if (!splitLines(content, settings_.limits.max_line_bytes, result.lines)) { + result.lines.clear(); + return reject(SourceCaptureDisposition::LineTooLong, bytes); + } + + result.record.disposition = SourceCaptureDisposition::Captured; + result.record.bytes = bytes; + record(result.record); + return result; +} + +} // namespace gpufl::detail diff --git a/include/gpufl/core/source_capture_policy.hpp b/include/gpufl/core/source_capture_policy.hpp new file mode 100644 index 0000000..00feaf2 --- /dev/null +++ b/include/gpufl/core/source_capture_policy.hpp @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace gpufl { + +/** + * Hard client-side bounds applied before correlated source is retained. + * + * These are capture limits, not backend request limits. Keeping them here + * prevents a profiler-reported path from causing an unbounded read in the + * target process. + */ +struct SourceCaptureLimits { + std::size_t max_files = 64; + std::uint64_t max_bytes_per_file = 1024 * 1024; + std::uint64_t max_total_bytes = 8 * 1024 * 1024; + std::size_t max_line_bytes = 64 * 1024; + std::size_t max_manifest_entries = 256; +}; + +/** + * Public source-capture policy carried by InitOptions and MonitorOptions. + * + * Empty approved_roots is deliberately fail-closed inside the collector. + * Normal startup fills it with the launcher's --source-root or the target's + * current working directory. Embedded callers can supply more than one root. + */ +struct SourceCaptureSettings { + std::vector approved_roots; + SourceCaptureLimits limits; +}; + +namespace detail { + +enum class SourceCaptureDisposition { + Captured, + Disabled, + InvalidPath, + NoApprovedRoot, + OutsideApprovedRoots, + SymlinkEscape, + ExcludedSystemRoot, + UnsupportedExtension, + NonTextContent, + NotRegularFile, + FileLimitExceeded, + FileTooLarge, + TotalBudgetExceeded, + LineTooLong, + ReadFailed, + ChangedDuringRead, +}; + +const char* sourceCaptureDispositionName(SourceCaptureDisposition value); + +struct SourceCaptureRecord { + std::uint32_t source_file_id = 0; + std::string logical_path; + std::string discovery_reason; + SourceCaptureDisposition disposition = + SourceCaptureDisposition::InvalidPath; + std::uint64_t bytes = 0; +}; + +struct SourceCaptureResult { + SourceCaptureRecord record; + std::vector lines; +}; + +struct SourceCaptureManifest { + bool enabled = false; + std::size_t approved_root_count = 0; + SourceCaptureLimits limits; + std::vector files; + std::uint64_t captured_files = 0; + std::uint64_t captured_bytes = 0; + std::uint64_t skipped_files = 0; + std::uint64_t omitted_manifest_entries = 0; +}; + +/** + * Stateful admission and bounded-read engine for discovered source paths. + * + * DictionaryManager serializes calls under its own mutex. This class does not + * lock internally and must not be shared without an owning lock. + */ +class SourceCapturePolicy { + public: + void configure(bool enabled, const SourceCaptureSettings& settings); + void reset(); + + SourceCaptureResult capture(const std::string& discovered_path, + std::uint32_t source_file_id, + const std::string& discovery_reason); + + const SourceCaptureManifest& manifest() const { return manifest_; } + bool manifestDirty() const { return manifest_dirty_; } + void markManifestFlushed() { manifest_dirty_ = false; } + + private: + std::string unavailableLogicalPath( + const std::filesystem::path& discovered_path, + std::uint32_t source_file_id) const; + void record(SourceCaptureRecord value); + + bool enabled_ = false; + SourceCaptureSettings settings_; + std::vector approved_roots_; + SourceCaptureManifest manifest_; + bool manifest_dirty_ = false; +}; + +} // namespace detail +} // namespace gpufl diff --git a/include/gpufl/core/startup_configuration.cpp b/include/gpufl/core/startup_configuration.cpp index 8bf3354..1ebbef0 100644 --- a/include/gpufl/core/startup_configuration.cpp +++ b/include/gpufl/core/startup_configuration.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "gpufl/core/config_file_loader.hpp" @@ -71,6 +72,18 @@ void resolveStartupOptions(InitOptions& options) { if (const char* value = std::getenv(env::kIncludeSource)) { options.enable_source_collection = std::strcmp(value, "1") == 0; } + if (const char* value = std::getenv(env::kSourceRoot); value && *value) { + options.source_capture.approved_roots = {value}; + } + if (options.enable_source_collection && + options.source_capture.approved_roots.empty()) { + std::error_code ec; + const std::filesystem::path current = + std::filesystem::current_path(ec); + if (!ec) { + options.source_capture.approved_roots = {current.string()}; + } + } std::string api_path = options.api_path; if (api_path.empty()) { diff --git a/include/gpufl/gpufl.hpp b/include/gpufl/gpufl.hpp index 29caf98..875fd40 100644 --- a/include/gpufl/gpufl.hpp +++ b/include/gpufl/gpufl.hpp @@ -49,6 +49,10 @@ struct InitOptions { bool enable_debug_output = false; bool enable_stack_trace = false; bool enable_source_collection = true; // collect source file content for source/SASS correlation + // Capture remains correlation-driven: these roots constrain which + // profiler-reported CUDA/C++ files may be opened. Normal startup supplies + // the current working directory when callers leave the list empty. + SourceCaptureSettings source_capture; bool flush_logs_always = false; // Enable CUPTI_ACTIVITY_KIND_EXTERNAL_CORRELATION so frameworks // (PyTorch's torch.profiler, TF's profile.trace, JAX, XLA) that diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6441b5b..ae77592 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -36,6 +36,7 @@ set(GPUFL_TEST_SOURCES core/test_segment_context.cpp core/test_segment_coordinator.cpp core/test_session_bootstrap.cpp + core/test_source_capture_policy.cpp core/test_startup_configuration.cpp upload/test_upload_logs.cpp # Launcher CLI parser test - portable (no CUDA / no POSIX). diff --git a/tests/core/test_monitor_configuration.cpp b/tests/core/test_monitor_configuration.cpp index f48cbac..38d59e3 100644 --- a/tests/core/test_monitor_configuration.cpp +++ b/tests/core/test_monitor_configuration.cpp @@ -62,6 +62,8 @@ TEST_F(MonitorConfigurationTest, CopiesEveryInitOptionWithNoEnvironmentOverride) options.enable_debug_output = true; options.enable_stack_trace = true; options.enable_source_collection = false; + options.source_capture.approved_roots = {"project-a", "project-b"}; + options.source_capture.limits.max_files = 12; options.enable_external_correlation = false; options.enable_synchronization = false; options.enable_memory_tracking = false; @@ -80,6 +82,9 @@ TEST_F(MonitorConfigurationTest, CopiesEveryInitOptionWithNoEnvironmentOverride) EXPECT_TRUE(actual.enable_debug_output); EXPECT_TRUE(actual.enable_stack_trace); EXPECT_FALSE(actual.enable_source_collection); + EXPECT_EQ(actual.source_capture.approved_roots, + (std::vector{"project-a", "project-b"})); + EXPECT_EQ(actual.source_capture.limits.max_files, 12u); EXPECT_FALSE(actual.enable_external_correlation); EXPECT_FALSE(actual.enable_synchronization); EXPECT_FALSE(actual.enable_memory_tracking); diff --git a/tests/core/test_source_capture_policy.cpp b/tests/core/test_source_capture_policy.cpp new file mode 100644 index 0000000..06e555b --- /dev/null +++ b/tests/core/test_source_capture_policy.cpp @@ -0,0 +1,264 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "gpufl/core/common.hpp" +#include "gpufl/core/dictionary_manager.hpp" +#include "gpufl/core/logger/log_sink.hpp" +#include "gpufl/core/logger/logger.hpp" +#include "gpufl/core/source_capture_policy.hpp" + +namespace { + +namespace fs = std::filesystem; +using gpufl::detail::SourceCaptureDisposition; + +class SourceCapturePolicyTest : public testing::Test { + protected: + void SetUp() override { + root_ = fs::temp_directory_path() / + ("gpufl_source_capture_" + + std::to_string(gpufl::detail::GetPid())); + std::error_code ec; + fs::remove_all(root_, ec); + fs::create_directories(root_ / "src", ec); + ASSERT_FALSE(ec) << ec.message(); + } + + void TearDown() override { + std::error_code ec; + fs::remove_all(root_, ec); + } + + fs::path write(const fs::path& relative, const std::string& content) { + const fs::path path = root_ / relative; + std::error_code ec; + fs::create_directories(path.parent_path(), ec); + EXPECT_FALSE(ec) << ec.message(); + std::ofstream output(path, std::ios::binary | std::ios::trunc); + EXPECT_TRUE(output.is_open()); + output.write(content.data(), static_cast(content.size())); + output.close(); + return path; + } + + gpufl::SourceCaptureSettings settings() const { + gpufl::SourceCaptureSettings value; + value.approved_roots = {root_.string()}; + return value; + } + + fs::path root_; +}; + +TEST_F(SourceCapturePolicyTest, CapturesApprovedCudaSourceWithLogicalPath) { + const fs::path source = write("src/kernel.cu", "line one\r\nline two\n"); + gpufl::detail::SourceCapturePolicy policy; + policy.configure(true, settings()); + + const auto result = policy.capture( + source.string(), 7, "profiler_source_correlation"); + + EXPECT_EQ(result.record.disposition, SourceCaptureDisposition::Captured); + EXPECT_EQ(result.record.source_file_id, 7u); + EXPECT_EQ(result.record.logical_path, "src/kernel.cu"); + EXPECT_EQ(result.record.bytes, 19u); + EXPECT_EQ(result.lines, + (std::vector{"line one", "line two"})); + EXPECT_EQ(policy.manifest().captured_files, 1u); + EXPECT_EQ(policy.manifest().captured_bytes, 19u); +} + +TEST_F(SourceCapturePolicyTest, RejectsPathsOutsideTheApprovedRoot) { + const fs::path outside = root_.parent_path() / "gpufl_outside_source.cu"; + { + std::ofstream output(outside, std::ios::binary | std::ios::trunc); + ASSERT_TRUE(output.is_open()); + output << "__global__ void outside() {}\n"; + } + gpufl::detail::SourceCapturePolicy policy; + policy.configure(true, settings()); + + const auto result = policy.capture(outside.string(), 2, "debug_line"); + + EXPECT_EQ(result.record.disposition, + SourceCaptureDisposition::OutsideApprovedRoots); + EXPECT_TRUE(result.lines.empty()); + EXPECT_EQ(result.record.logical_path, + "unavailable/source-2/gpufl_outside_source.cu"); + std::error_code ec; + fs::remove(outside, ec); +} + +TEST_F(SourceCapturePolicyTest, RejectsUnsupportedAndNonTextFiles) { + const fs::path unsupported = write("src/notes.txt", "not source\n"); + const fs::path binary = write("src/binary.cu", std::string("a\0b", 3)); + gpufl::detail::SourceCapturePolicy policy; + policy.configure(true, settings()); + + EXPECT_EQ(policy.capture(unsupported.string(), 1, "debug_line") + .record.disposition, + SourceCaptureDisposition::UnsupportedExtension); + EXPECT_EQ(policy.capture(binary.string(), 2, "debug_line") + .record.disposition, + SourceCaptureDisposition::NonTextContent); +} + +TEST_F(SourceCapturePolicyTest, EnforcesFileTotalAndLineBudgets) { + const fs::path first = write("src/first.cu", "a\nb\n"); + const fs::path total = write("src/total.cu", "c\nd\ne\n"); + const fs::path large = write("src/large.cu", "123456789"); + const fs::path long_line = write("src/line.cu", "12345\n"); + gpufl::detail::SourceCapturePolicy policy; + + auto file_config = settings(); + file_config.limits.max_bytes_per_file = 8; + policy.configure(true, file_config); + EXPECT_EQ(policy.capture(large.string(), 1, "debug_line") + .record.disposition, + SourceCaptureDisposition::FileTooLarge); + + auto line_config = settings(); + line_config.limits.max_line_bytes = 4; + policy.configure(true, line_config); + EXPECT_EQ(policy.capture(long_line.string(), 2, "debug_line") + .record.disposition, + SourceCaptureDisposition::LineTooLong); + + auto total_config = settings(); + total_config.limits.max_total_bytes = 5; + policy.configure(true, total_config); + EXPECT_EQ(policy.capture(total.string(), 3, "debug_line") + .record.disposition, + SourceCaptureDisposition::TotalBudgetExceeded); + + auto count_config = settings(); + count_config.limits.max_files = 1; + policy.configure(true, count_config); + EXPECT_EQ(policy.capture(first.string(), 4, "debug_line") + .record.disposition, + SourceCaptureDisposition::Captured); + EXPECT_EQ(policy.capture(total.string(), 5, "debug_line") + .record.disposition, + SourceCaptureDisposition::FileLimitExceeded); +} + +TEST_F(SourceCapturePolicyTest, OptOutAndMissingRootFailClosed) { + const fs::path source = write("src/kernel.cu", "kernel\n"); + gpufl::detail::SourceCapturePolicy policy; + policy.configure(false, settings()); + EXPECT_EQ(policy.capture(source.string(), 1, "debug_line") + .record.disposition, + SourceCaptureDisposition::Disabled); + + gpufl::SourceCaptureSettings no_roots; + policy.configure(true, no_roots); + EXPECT_EQ(policy.capture(source.string(), 2, "debug_line") + .record.disposition, + SourceCaptureDisposition::NoApprovedRoot); +} + +TEST_F(SourceCapturePolicyTest, RejectsDirectoryEvenWithSourceExtension) { + const fs::path directory = root_ / "src/not-a-file.cu"; + std::error_code ec; + fs::create_directory(directory, ec); + ASSERT_FALSE(ec) << ec.message(); + gpufl::detail::SourceCapturePolicy policy; + policy.configure(true, settings()); + + EXPECT_EQ(policy.capture(directory.string(), 1, "debug_line") + .record.disposition, + SourceCaptureDisposition::NotRegularFile); +} + +TEST_F(SourceCapturePolicyTest, RejectsSymlinkThatEscapesTheApprovedRoot) { + const fs::path outside = root_.parent_path() / "gpufl_symlink_target.cu"; + { + std::ofstream output(outside, std::ios::binary | std::ios::trunc); + ASSERT_TRUE(output.is_open()); + output << "__global__ void escaped() {}\n"; + } + const fs::path link = root_ / "src/escaped.cu"; + std::error_code ec; + fs::create_symlink(outside, link, ec); + if (ec) { + const std::string message = ec.message(); + std::error_code cleanup_ec; + fs::remove(outside, cleanup_ec); + GTEST_SKIP() << "Creating symlinks is unavailable: " << message; + } + gpufl::detail::SourceCapturePolicy policy; + policy.configure(true, settings()); + + EXPECT_EQ(policy.capture(link.string(), 1, "debug_line") + .record.disposition, + SourceCaptureDisposition::SymlinkEscape); + fs::remove(outside, ec); +} + +TEST_F(SourceCapturePolicyTest, BoundsDetailedManifestEntries) { + auto config = settings(); + config.limits.max_manifest_entries = 1; + const fs::path first = write("src/first.txt", "one\n"); + const fs::path second = write("src/second.txt", "two\n"); + gpufl::detail::SourceCapturePolicy policy; + policy.configure(true, config); + + policy.capture(first.string(), 1, "debug_line"); + policy.capture(second.string(), 2, "debug_line"); + + EXPECT_EQ(policy.manifest().files.size(), 1u); + EXPECT_EQ(policy.manifest().skipped_files, 2u); + EXPECT_EQ(policy.manifest().omitted_manifest_entries, 1u); +} + +class RecordingSink final : public gpufl::ILogSink { + public: + explicit RecordingSink(std::shared_ptr> lines) + : lines_(std::move(lines)) {} + void write(gpufl::Channel, std::string_view json) override { + lines_->emplace_back(json); + } + void close() override {} + + private: + std::shared_ptr> lines_; +}; + +TEST_F(SourceCapturePolicyTest, DictionaryEmitsContentAndBoundedManifest) { + const fs::path source = write("src/kernel.cu", "first\nsecond\n"); + gpufl::DictionaryManager dictionary; + dictionary.configureSourceCapture(true, settings()); + EXPECT_EQ(dictionary.internSourceFile(source.string()), 1u); + + auto lines = std::make_shared>(); + gpufl::Logger logger; + logger.addSink(std::make_unique(lines)); + dictionary.flushDictionary(logger, "session-1"); + dictionary.flushSourceContent(logger, "session-1"); + + ASSERT_EQ(lines->size(), 3u); + EXPECT_NE((*lines)[0].find(R"("type":"dictionary_update")"), + std::string::npos); + EXPECT_NE((*lines)[0].find(R"("source_file_dict":{"1":"src/kernel.cu"})"), + std::string::npos); + EXPECT_EQ((*lines)[0].find(root_.string()), std::string::npos); + EXPECT_NE((*lines)[1].find(R"("type":"source_capture_manifest")"), + std::string::npos); + EXPECT_NE((*lines)[1].find(R"("logical_path":"src/kernel.cu")"), + std::string::npos); + EXPECT_NE((*lines)[1].find(R"("disposition":"captured")"), + std::string::npos); + EXPECT_EQ((*lines)[1].find(root_.string()), std::string::npos); + EXPECT_NE((*lines)[2].find(R"("type":"source_file_content")"), + std::string::npos); + EXPECT_NE((*lines)[2].find(R"("lines":["first","second"])"), + std::string::npos); +} + +} // namespace diff --git a/tests/core/test_startup_configuration.cpp b/tests/core/test_startup_configuration.cpp index 85906c7..ff00007 100644 --- a/tests/core/test_startup_configuration.cpp +++ b/tests/core/test_startup_configuration.cpp @@ -34,6 +34,7 @@ class StartupConfigurationTest : public testing::Test { saveAndUnset_(gpufl::env::kConfigFile, config_file_); saveAndUnset_(gpufl::env::kApiPath, api_path_); saveAndUnset_(gpufl::env::kIncludeSource, include_source_); + saveAndUnset_(gpufl::env::kSourceRoot, source_root_); saveAndUnset_(gpufl::env::kRunId, run_id_); saveAndUnset_(gpufl::env::kSegmentEveryMs, segment_every_ms_); saveAndUnset_(gpufl::env::kSegmentMaxRows, segment_max_rows_); @@ -45,6 +46,7 @@ class StartupConfigurationTest : public testing::Test { restore_(gpufl::env::kConfigFile, config_file_); restore_(gpufl::env::kApiPath, api_path_); restore_(gpufl::env::kIncludeSource, include_source_); + restore_(gpufl::env::kSourceRoot, source_root_); restore_(gpufl::env::kRunId, run_id_); restore_(gpufl::env::kSegmentEveryMs, segment_every_ms_); restore_(gpufl::env::kSegmentMaxRows, segment_max_rows_); @@ -67,6 +69,7 @@ class StartupConfigurationTest : public testing::Test { std::optional config_file_; std::optional api_path_; std::optional include_source_; + std::optional source_root_; std::optional run_id_; std::optional segment_every_ms_; std::optional segment_max_rows_; @@ -114,6 +117,21 @@ TEST_F(StartupConfigurationTest, IncludeSourceEnvironmentEnablesCollection) { gpufl::detail::resolveStartupOptions(options); EXPECT_TRUE(options.enable_source_collection); + ASSERT_EQ(options.source_capture.approved_roots.size(), 1u); + EXPECT_FALSE(options.source_capture.approved_roots.front().empty()); +} + +TEST_F(StartupConfigurationTest, LauncherSourceRootOverridesProgrammaticRoots) { + const auto root = std::filesystem::temp_directory_path() / + "gpufl-explicit-source-root"; + setEnv(gpufl::env::kSourceRoot, root.string().c_str()); + + gpufl::InitOptions options; + options.source_capture.approved_roots = {"stale-root"}; + gpufl::detail::resolveStartupOptions(options); + + EXPECT_EQ(options.source_capture.approved_roots, + (std::vector{root.string()})); } TEST_F(StartupConfigurationTest, IncludeSourceEnvironmentOverridesConfigFile) { diff --git a/tests/launcher/test_cli_parse.cpp b/tests/launcher/test_cli_parse.cpp index 9dad71f..998acba 100644 --- a/tests/launcher/test_cli_parse.cpp +++ b/tests/launcher/test_cli_parse.cpp @@ -295,6 +295,11 @@ TEST(CliParseTrace, SourceCaptureDefaultsOnAndCanBeDisabled) { argsFor({"--no-source", "--", "./bin"})); ASSERT_TRUE(opted_out.args.has_value()) << opted_out.error; EXPECT_TRUE(opted_out.args->no_source); + + auto rooted = parseTraceArgs( + argsFor({"--source-root", "./cuda-project", "--", "./bin"})); + ASSERT_TRUE(rooted.args.has_value()) << rooted.error; + EXPECT_EQ(rooted.args->source_root, "./cuda-project"); } TEST(CliParseTrace, BooleanFlagsRejectInlineValues) { @@ -309,6 +314,7 @@ TEST(CliParseHelp, TraceSimpleOptionsComeFromTheRegistry) { EXPECT_NE(help.find("-n, --name="), std::string::npos); EXPECT_NE(help.find("--backend-url="), std::string::npos); EXPECT_NE(help.find("--no-source"), std::string::npos); + EXPECT_NE(help.find("--source-root="), std::string::npos); EXPECT_NE(help.find("GPUFL_BACKEND_URL"), std::string::npos); } diff --git a/tests/launcher/test_trace_run_plan.cpp b/tests/launcher/test_trace_run_plan.cpp index 7b3585e..9099b5c 100644 --- a/tests/launcher/test_trace_run_plan.cpp +++ b/tests/launcher/test_trace_run_plan.cpp @@ -168,6 +168,7 @@ TEST(TraceRunPlanTest, TraceCommonExecutesThePlannedSinglePass) { args.command = {"target", "--work"}; args.name = "trace-plan-test"; args.output_dir = (root / "requested-output").string(); + args.source_root = root.string(); args.warmup_ms = 100; args.window_ms = 200; @@ -178,6 +179,8 @@ TEST(TraceRunPlanTest, TraceCommonExecutesThePlannedSinglePass) { EXPECT_EQ(platform.env[gpufl::env::kLogDir], args.output_dir); EXPECT_EQ(platform.env[gpufl::env::kProfilingEngine], "Trace"); EXPECT_EQ(platform.env[gpufl::env::kIncludeSource], "1"); + EXPECT_EQ(fs::weakly_canonical(platform.env[gpufl::env::kSourceRoot]), + fs::weakly_canonical(root)); fs::remove_all(root, ec); } @@ -198,10 +201,13 @@ TEST(TraceRunPlanTest, TraceCommonHonorsExplicitSourceCaptureOptOut) { TraceArgs args; args.command = {"target"}; args.output_dir = (root / "capture").string(); + args.source_root = root.string(); args.no_source = true; EXPECT_EQ(gpufl::launcher::runTraceCommon(args, platform), 0); EXPECT_EQ(platform.env[gpufl::env::kIncludeSource], "0"); + EXPECT_EQ(fs::weakly_canonical(platform.env[gpufl::env::kSourceRoot]), + fs::weakly_canonical(root)); fs::remove_all(root, ec); }