From 28a61b12d302e9e0d37d52bf1aa5d19069f3411b Mon Sep 17 00:00:00 2001 From: Dan Engelbrecht Date: Mon, 20 Apr 2026 15:53:22 +0200 Subject: zen history command (#987) - Feature: Per-user invocation history for `zen` and `zenserver`; each startup appends a record to a JSONL file capped at the most recent 100 entries. Location: `%LOCALAPPDATA%\Epic\Zen\History\invocations.jsonl` on Windows, `~/.zen/History/invocations.jsonl` on POSIX - `zen history` opens an interactive picker; selecting a zen row re-runs it inline and forwards the exit code, selecting a zenserver row spawns it detached - `zen history --list` (`-l`) prints the table to stdout instead of showing the picker - `zen history --filter zen|zenserver` restricts the listing to one executable - `zen history --print` prints the reconstructed command line of the selected row instead of launching it - `--enable-execution-history` global option on both binaries (default `true`) to opt out per invocation - The history file is attached to Sentry crash reports (alongside the existing zenserver log) --- src/zenutil/invocationhistory.cpp | 310 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 src/zenutil/invocationhistory.cpp (limited to 'src/zenutil/invocationhistory.cpp') diff --git a/src/zenutil/invocationhistory.cpp b/src/zenutil/invocationhistory.cpp new file mode 100644 index 000000000..49fdff31d --- /dev/null +++ b/src/zenutil/invocationhistory.cpp @@ -0,0 +1,310 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include + +#include +#include +#include +#include +#include +#include +#include + +ZEN_THIRD_PARTY_INCLUDES_START +#include +ZEN_THIRD_PARTY_INCLUDES_END + +#include +#include + +namespace zen { + +namespace { + + constexpr size_t kMaxRecords = 100; + constexpr std::string_view kHistoryFileName = "invocations.jsonl"; + + // Safety cap. With 100 records at typical ~500-1000 bytes each the file + // normally sits around 50-100 KB. If it has grown past this threshold + // (external corruption, runaway producer, another tool writing garbage) + // we refuse to read it and start fresh with just the new record. Keeps + // LogInvocation from slowing startup on a pathological file. + constexpr uintmax_t kMaxReadSize = 1 * 1024 * 1024; // 1 MB + + bool ExecutionHistoryDisabled(int argc, char** argv) + { + for (int I = 1; I < argc; ++I) + { + if (argv[I] == nullptr) + { + continue; + } + std::string_view A = argv[I]; + if (A == "--enable-execution-history=false" || A == "--enable-execution-history=0" || A == "--enable-execution-history=no") + { + return true; + } + } + return false; + } + + std::filesystem::path ResolveHistoryDir() + { +#if ZEN_PLATFORM_WINDOWS + std::string LocalAppData = GetEnvVariable("LOCALAPPDATA"); + if (!LocalAppData.empty()) + { + return std::filesystem::path(LocalAppData) / "Epic" / "Zen" / "History"; + } +#endif + std::filesystem::path SystemRoot = PickDefaultSystemRootDirectory(); + if (SystemRoot.empty()) + { + return {}; + } + return SystemRoot / "History"; + } + + std::string BuildJsonRecord(const HistoryRecord& Rec) + { + json11::Json::object Obj{ + {"id", Rec.Id}, + {"ts", Rec.Ts}, + {"exe", Rec.Exe}, + {"pid", static_cast(Rec.Pid)}, + {"cwd", Rec.Cwd}, + {"path", Rec.Path}, + {"cmdline", Rec.CmdLine}, + }; + if (!Rec.Mode.empty()) + { + Obj.emplace("mode", Rec.Mode); + } + return json11::Json(Obj).dump(); + } + + bool ParseJsonRecord(std::string_view Line, HistoryRecord& OutRec) + { + std::string Err; + json11::Json J = json11::Json::parse(std::string(Line), Err); + if (!Err.empty() || !J.is_object()) + { + return false; + } + OutRec.Id = J["id"].string_value(); + OutRec.Ts = J["ts"].string_value(); + OutRec.Exe = J["exe"].string_value(); + OutRec.Mode = J["mode"].string_value(); + OutRec.Cwd = J["cwd"].string_value(); + OutRec.Path = J["path"].string_value(); + OutRec.CmdLine = J["cmdline"].string_value(); + OutRec.Pid = static_cast(J["pid"].int_value()); + return true; + } + + std::vector ReadHistoryLines(const std::filesystem::path& Path) + { + std::vector Lines; + + std::error_code SizeEc; + const std::uintmax_t FileSize = std::filesystem::file_size(Path, SizeEc); + if (SizeEc || FileSize > kMaxReadSize) + { + return Lines; + } + + FileContents Contents = ReadFile(Path); + if (!Contents) + { + return Lines; + } + IoBuffer Flat = Contents.Flatten(); + const char* Data = static_cast(Flat.GetData()); + const size_t Size = Flat.GetSize(); + size_t Start = 0; + for (size_t I = 0; I < Size; ++I) + { + if (Data[I] == '\n') + { + if (I > Start) + { + size_t LineEnd = I; + if (LineEnd > Start && Data[LineEnd - 1] == '\r') + { + --LineEnd; + } + if (LineEnd > Start) + { + Lines.emplace_back(Data + Start, LineEnd - Start); + } + } + Start = I + 1; + } + } + if (Start < Size) + { + Lines.emplace_back(Data + Start, Size - Start); + } + return Lines; + } + +} // namespace + +std::filesystem::path +GetInvocationHistoryPath() noexcept +{ + try + { + std::filesystem::path Dir = ResolveHistoryDir(); + if (Dir.empty()) + { + return {}; + } + return Dir / kHistoryFileName; + } + catch (...) + { + return {}; + } +} + +void +LogInvocation(std::string_view Exe, + std::string_view Mode, + int argc, + char** argv, + std::initializer_list ExcludeSubcommands) noexcept +{ + try + { + if (ExecutionHistoryDisabled(argc, argv)) + { + return; + } + + if (argc >= 2 && argv[1] != nullptr) + { + std::string_view A1 = argv[1]; + for (std::string_view Excluded : ExcludeSubcommands) + { + if (A1 == Excluded) + { + return; + } + } + } + + std::filesystem::path Dir = ResolveHistoryDir(); + if (Dir.empty()) + { + return; + } + + std::error_code Ec; + CreateDirectories(Dir, Ec); + if (Ec) + { + return; + } + + std::filesystem::path Path = Dir / kHistoryFileName; + + HistoryRecord Rec; + Rec.Id = Oid::NewOid().ToString(); + Rec.Ts = DateTime::Now().ToIso8601(); + Rec.Exe = std::string(Exe); + Rec.Mode = std::string(Mode); + + std::error_code CwdEc; + Rec.Cwd = std::filesystem::current_path(CwdEc).string(); + + Rec.Path = GetRunningExecutablePath().string(); + Rec.Pid = static_cast(GetCurrentProcessId()); + + std::string Raw = GetRawCommandLine(); + if (!Raw.empty()) + { + Rec.CmdLine = std::move(Raw); + } + else + { + std::vector Args; + Args.reserve(argc); + for (int I = 0; I < argc; ++I) + { + if (argv[I] != nullptr) + { + Args.emplace_back(argv[I]); + } + } + Rec.CmdLine = BuildCommandLine(Args); + } + + std::vector Lines = ReadHistoryLines(Path); + if (Lines.size() >= kMaxRecords) + { + Lines.erase(Lines.begin(), Lines.begin() + (Lines.size() - (kMaxRecords - 1))); + } + Lines.push_back(BuildJsonRecord(Rec)); + + std::string NewContents; + size_t TotalSize = 0; + for (const std::string& L : Lines) + { + TotalSize += L.size() + 1; + } + NewContents.reserve(TotalSize); + for (const std::string& L : Lines) + { + NewContents.append(L); + NewContents.push_back('\n'); + } + + std::error_code WriteEc; + TemporaryFile::SafeWriteFile(Path, MemoryView(NewContents.data(), NewContents.size()), WriteEc); + } + catch (...) + { + } +} + +std::vector +ReadInvocationHistory(size_t MaxRecords) +{ + std::vector Records; + try + { + std::filesystem::path Path = GetInvocationHistoryPath(); + if (Path.empty()) + { + return Records; + } + + std::vector Lines = ReadHistoryLines(Path); + if (Lines.size() > MaxRecords) + { + Lines.erase(Lines.begin(), Lines.begin() + (Lines.size() - MaxRecords)); + } + + Records.reserve(Lines.size()); + for (const std::string& L : Lines) + { + HistoryRecord Rec; + if (ParseJsonRecord(L, Rec)) + { + Records.push_back(std::move(Rec)); + } + } + } + catch (...) + { + } + return Records; +} + +void +invocationhistory_forcelink() +{ +} + +} // namespace zen -- cgit v1.2.3 From 6aa4efa21a09990998a4054e805e595ef38ae785 Mon Sep 17 00:00:00 2001 From: Dan Engelbrecht Date: Mon, 20 Apr 2026 22:13:10 +0200 Subject: hide secrets from log and sentry (#989) * scrub sensitive command line options from log and sentry --- src/zenutil/invocationhistory.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) (limited to 'src/zenutil/invocationhistory.cpp') diff --git a/src/zenutil/invocationhistory.cpp b/src/zenutil/invocationhistory.cpp index 49fdff31d..077061752 100644 --- a/src/zenutil/invocationhistory.cpp +++ b/src/zenutil/invocationhistory.cpp @@ -9,12 +9,12 @@ #include #include #include +#include ZEN_THIRD_PARTY_INCLUDES_START #include ZEN_THIRD_PARTY_INCLUDES_END -#include #include namespace zen { @@ -222,11 +222,7 @@ LogInvocation(std::string_view Exe, Rec.Pid = static_cast(GetCurrentProcessId()); std::string Raw = GetRawCommandLine(); - if (!Raw.empty()) - { - Rec.CmdLine = std::move(Raw); - } - else + if (Raw.empty()) { std::vector Args; Args.reserve(argc); @@ -237,8 +233,10 @@ LogInvocation(std::string_view Exe, Args.emplace_back(argv[I]); } } - Rec.CmdLine = BuildCommandLine(Args); + Raw = BuildCommandLine(Args); } + ScrubSensitiveValues(Raw); + Rec.CmdLine = std::move(Raw); std::vector Lines = ReadHistoryLines(Path); if (Lines.size() >= kMaxRecords) -- cgit v1.2.3