From 92ad73df9b75a489e5284b77b5fec11f36ab5a54 Mon Sep 17 00:00:00 2001 From: stacknil <219124580+stacknil@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:22:53 +0800 Subject: [PATCH] fix(parser): reject day overflow before calendar conversion --- CMakeLists.txt | 12 ++ FUZZING-OR-BOUNDARY-TEST-REPORT.md | 184 ++++++++++++++++++ RELIABILITY-AUDIT.md | 84 ++++++++ docs/parser-contract.md | 5 + fuzz/timestamp_day_fuzz.cpp | 49 +++++ src/parser/timestamp_parser.cpp | 5 + tests/fuzz/README.md | 6 + tests/fuzz/corpus/timestamp_day/valid_day | 1 + .../corpus/timestamp_day/wrapped_negative | 1 + .../corpus/timestamp_day/wrapped_positive | 1 + tests/test_timestamp_boundaries.cpp | 120 ++++++++++++ 11 files changed, 468 insertions(+) create mode 100644 FUZZING-OR-BOUNDARY-TEST-REPORT.md create mode 100644 RELIABILITY-AUDIT.md create mode 100644 fuzz/timestamp_day_fuzz.cpp create mode 100644 tests/fuzz/corpus/timestamp_day/valid_day create mode 100644 tests/fuzz/corpus/timestamp_day/wrapped_negative create mode 100644 tests/fuzz/corpus/timestamp_day/wrapped_positive create mode 100644 tests/test_timestamp_boundaries.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b567db2..22addbd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -83,6 +83,11 @@ if(BUILD_TESTING) loglens_enable_warnings(test_parser_properties) add_test(NAME parser_properties COMMAND test_parser_properties) + add_executable(test_timestamp_boundaries tests/test_timestamp_boundaries.cpp) + target_link_libraries(test_timestamp_boundaries PRIVATE loglens_lib) + loglens_enable_warnings(test_timestamp_boundaries) + add_test(NAME timestamp_boundaries COMMAND test_timestamp_boundaries) + add_executable(test_detector tests/test_detector.cpp) target_link_libraries(test_detector PRIVATE loglens_lib) loglens_enable_warnings(test_detector) @@ -159,4 +164,11 @@ if(LOGLENS_BUILD_FUZZERS) -fno-omit-frame-pointer ) target_link_options(fuzz_parser PRIVATE -fsanitize=fuzzer,address,undefined) + + add_executable(fuzz_timestamp_day fuzz/timestamp_day_fuzz.cpp) + target_link_libraries(fuzz_timestamp_day PRIVATE loglens_fuzz_lib) + loglens_enable_warnings(fuzz_timestamp_day) + target_compile_options(fuzz_timestamp_day PRIVATE + -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer) + target_link_options(fuzz_timestamp_day PRIVATE -fsanitize=fuzzer,address,undefined) endif() diff --git a/FUZZING-OR-BOUNDARY-TEST-REPORT.md b/FUZZING-OR-BOUNDARY-TEST-REPORT.md new file mode 100644 index 0000000..244566c --- /dev/null +++ b/FUZZING-OR-BOUNDARY-TEST-REPORT.md @@ -0,0 +1,184 @@ +# Executed boundary test report + +Date: 2026-10-15. Scope: syslog integer day to calendar conversion only. +Result: **ACCEPT — confirmed defect with failing regression and validated fix**. + +## Environment + +Linux under WSL; GCC/G++ 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1), libstdc++, +CMake 3.28.3, Ninja 1.11.1, Python 3.12.3, jsonschema 4.10.3. Clang/libFuzzer +was initially unavailable. With explicit user authorization, the distribution +packages `clang` and `libclang-rt-dev` were installed, providing Ubuntu Clang +18.1.3 and compiler-rt/libFuzzer 18. The existing general parser fuzz harness +and CI smoke configuration are unchanged; a focused day-token target is added. + +## Corpus and oracle + +`tests/test_timestamp_boundaries.cpp` constructs the corpus deterministically; +there is no random seed, external input, or downloaded dataset. Each line uses +this synthetic template, with a substituted month/day: + +```text +Mar 257 08:30:00 example-host sshd[4200]: Failed password for user-a from 203.0.113.10 port 50101 ssh2 +``` + +The explicit 257 witness runs first. The full corpus contains: + +| Cases | Inputs / purpose | +| ---: | --- | +| 1 | Minimal positive wrap witness: day 257 | +| 2,049 | March day integers -1024..1024 inclusive | +| 124 | Valid days 1..31 shifted by -65536, -256, 256, 65536; none may alias | +| 8 | INT_MAX, INT_MIN, both adjacent overflow strings, `1x`, bytes `31 00`, bytes `31 ff`, and 4,096 ASCII nines | +| 1 | `01` remains accepted as day 1 | +| 744 | All months, days 1..31, in leap year 2024 and common year 2026 | +| **2,927** | **Total parse_line cases, including intentional overlap** | + +An additional three-line stream contains valid day 1, invalid day 257, valid day 2. +It must emit two events and one timestamp warning, preserve line numbers 1/3/2, +and account for all three lines. The oracle uses explicit integer domain and +month lengths; it never constructs a `chrono::day` from an adversarial value. +For accepted values it also checks exact timestamp, line number, and cleared +error text. Rejected values check category and reason. INT overflow/invalid +bytes remain `invalid day token`; representable out-of-domain integers become +`invalid calendar date`. + +## Commands and observed baseline + +Commands below run from the repository root in a Linux shell. Build directories +are local only. The non-PIE flags select a fixed executable layout for this +local GCC sanitizer build; they are not project defaults or a product change. + +At baseline `e2937e8`, before adding tests or changing production code: + +```sh +cmake -S . -B build-reliability -G Ninja -DCMAKE_BUILD_TYPE=Debug \ + -DLOGLENS_WARNINGS_AS_ERRORS=ON \ + '-DCMAKE_CXX_FLAGS=-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer -fno-pie' \ + -DCMAKE_EXE_LINKER_FLAGS=-no-pie +cmake --build build-reliability -j 4 +ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=halt_on_error=1 \ + ctest --test-dir build-reliability --output-on-failure +``` + +Baseline: build succeeded; **7/7 CTest entries passed in 3.39 seconds**, no +ASan/UBSan findings. After adding only the new test and CMake registration: + +```sh +cmake --build build-reliability --target test_timestamp_boundaries -j 4 +ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=halt_on_error=1 \ + ctest --test-dir build-reliability -R timestamp_boundaries --output-on-failure +``` + +Result: **0/1 passed**, CTest failure, `invalid day accepted at case 1`. +The test failed in 0.03 seconds (CTest total 0.05 seconds). This demonstrates +that the existing green suite did not detect invalid-day acceptance. + +## Fixed implementation validation + +After adding the pre-conversion 1..31 guard: + +```sh +cmake --build build-reliability -j 4 +ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=halt_on_error=1 \ + ctest --test-dir build-reliability -R timestamp_boundaries -V +ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=halt_on_error=1 \ + ctest --test-dir build-reliability --output-on-failure +``` + +Focused result: **2,927 cases and one three-line stream passed**, 0.07 seconds +(CTest total 0.10 seconds). Full result: **8/8 passed, 3.81 seconds**. No +ASan, leak, or UBSan findings were observed. All C++ targets in this build, +including the library and CLI, receive the sanitizer compile flags; Python +research tests run normally and are not C++ sanitizer coverage. + +An additional non-sanitized optimized build uses: + +```sh +cmake -S . -B build/reliability-release -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DLOGLENS_WARNINGS_AS_ERRORS=ON +cmake --build build/reliability-release -j 4 +ctest --test-dir build/reliability-release --output-on-failure +git diff --check +``` + +Release build succeeded: **8/8 passed in 2.06 seconds**. Warnings-as-errors applies +to both builds. No dedicated formatter/type-check target is configured; the +compiler and test suite provide the applicable checks here. + +## Focused libFuzzer execution + +The new optional `fuzz_timestamp_day` target uses the existing instrumented +`loglens_fuzz_lib`. It substitutes arbitrary bytes into the day token of one +fixed March 2026 SSH failure line. Empty inputs and tokens containing ASCII +whitespace return early to keep envelope injection outside this target. +An independent integer-domain check requires acceptance exactly when the +complete token parses as an integer in 1..31, then checks the timestamp. +It shares `from_chars` with production, so it is not an independent lexical +oracle; its independent property is validation before narrowing. Rejections +must retain timestamp telemetry. The existing general parser fuzzer alone +would not detect this bug because an incorrectly accepted event still meets +its type/program invariants. + +Three committed seeds contain exact ASCII bytes without newlines: `257`, +`-255`, `31`. Mutated corpus entries stay in an ignored build directory. +Installation (as root), followed by the build/run from the repository root: + +```sh +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends clang libclang-rt-dev +clang++ --version +cmake -S . -B build/reliability-fuzz -G Ninja \ + -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DBUILD_TESTING=OFF -DLOGLENS_BUILD_FUZZERS=ON \ + -DLOGLENS_WARNINGS_AS_ERRORS=ON -DCMAKE_CXX_FLAGS=-fno-sanitize-recover=all +cmake --build build/reliability-fuzz --target fuzz_timestamp_day -j 4 +mkdir -p build/reliability-fuzz/day-corpus +cp tests/fuzz/corpus/timestamp_day/* build/reliability-fuzz/day-corpus/ +ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=halt_on_error=1 \ + ./build/reliability-fuzz/fuzz_timestamp_day -runs=10000 -seed=20261015 \ + -max_len=4096 -timeout=2 -print_final_stats=1 build/reliability-fuzz/day-corpus +``` + +For a fresh replay, use a fresh build/corpus directory containing only those +three seeds. Mutation sequences and coverage counts can vary with toolchain. +The run count includes harness calls that skip empty/whitespace tokens; it is +not the number of distinct inputs or accepted parser calls. The maximum length +is a cap, not a claim that fuzzing exercised all sizes up to that length; the +deterministic test separately covers the 4,096-digit token. + +Observed result: exit 0, **10,000 executed units**, no oracle failure, crash, +timeout, ASan, leak, or UBSan diagnostic. libFuzzer reported `cov: 227`, +`ft: 288`, final corpus 25 entries / 268 bytes, peak RSS 40 MB, and elapsed +`0 second(s)` at its whole-second resolution (not zero execution cost). +Its final growth limit was 80 bytes; this run did not reach the 4,096-byte cap. +These coverage counters are build-specific feedback, not source-line coverage +percentages or evidence of all paths being covered. + +Final integration check also built both optional fuzz executables and reran the +Release suite after the CMake addition: + +```sh +cmake --build build/reliability-fuzz --target fuzz_parser fuzz_timestamp_day -j 4 +cmake --build build/reliability-release -j 4 +ctest --test-dir build/reliability-release --output-on-failure +git diff --cached --check +``` + +Both fuzz targets built successfully; Release CTest passed **8/8 in 1.97 seconds**. +Diff whitespace checks passed. Final source/doc review and a scoped scan found +no private machine paths, personal identifiers, or credential patterns in the +new artifacts; only synthetic fixture identities are included. + +## Interpretation + +The production diff is a five-line guard/comment, with CTest registration, +deterministic tests, a focused fuzz oracle/corpus, and contract/evidence documentation. The new test is part +of ordinary CTest, so existing Linux/Windows CI definitions will discover it +when a future authorized push occurs; remote CI was not run here. + +This is semantic regression evidence and bounded dynamic sanitizer/fuzz evidence, +not exhaustive memory safety, exhaustive fuzz coverage, or a performance benchmark. +No default detection algorithm, frozen experiment, source dataset, or existing +golden was changed. Fixtures use reserved documentation addresses and synthetic +identities. No push, PR, merge, tag, or release was performed. diff --git a/RELIABILITY-AUDIT.md b/RELIABILITY-AUDIT.md new file mode 100644 index 0000000..8a7378e --- /dev/null +++ b/RELIABILITY-AUDIT.md @@ -0,0 +1,84 @@ +# Reliability audit: syslog day conversion + +Date: 2026-10-15. Baseline: `e2937e8`. Local branch: +`reliability/syslog-day-boundaries`. Decision: **ACCEPT**, completion criterion A. +This is a bounded source review and executed regression, not a complete security audit. + +## Inventory and contracts + +- C++20, extensions disabled; CMake >= 3.20, CTest, standard-library parser. +- Six baseline C++ test executables cover parsing, generated properties, + detectors, reports, CLI, and report contracts. An optional seventh CTest entry + runs the Python episode-candidate research tests. +- CLI input reaches `AuthLogParser::parse_file`, then `parse_stream` (`getline`), + then `parse_line`. Internal stages are timestamp/hostname, source envelope, + source-IP classification, program dispatch, and handler normalization. +- Configuration uses its own JSON cursor; CLI year conversion and parser integer + conversion use `from_chars`. CLI/config years are restricted to 1000..9999. +- `parse_line` returns an optional owning `Event`, resetting caller-supplied + diagnostics before parsing. Temporary `string_view` tokens borrow input; + event strings own their data. `parse_stream` accumulates events and warnings. + `parse_file` throws on open failure. Allocation failure/resource exhaustion + is not converted into a per-line warning. Stream I/O failure is a separate, + untested boundary in this increment. +- Existing deterministic properties include 512 arbitrary-byte inputs in both + modes, malformed IPv4 tokens, telemetry stability, and handler permutations. +- Existing `fuzz/parser_fuzz.cpp` invokes both modes with seven corpus seeds. + CMake instruments the fuzz library and driver with ASan/UBSan/libFuzzer. + CI defines a 2,000-run, 1,024-byte fuzz smoke job, Linux/Windows CTest jobs, + and C++ CodeQL analysis. These are configuration observations, not claims of + a newly executed remote CI campaign. +- `docs/adr/0001-episode-semantics-boundaries.md` keeps research candidate + algorithms distinct from production detection. No research fixtures, results, + candidate algorithms, finding identities, signal defaults, or report schema + are edited. Paper A1 is entirely excluded. + +## Candidate assessment + +Likelihood below means plausibility from inspected code, not measured prevalence. +Only the selected target received new experimental coverage. + +| Surface | Likelihood | Potential impact | Reproducibility / testability | Decision | +| --- | --- | --- | --- | --- | +| Truncated/malformed auth lines | Common input; defect unconfirmed | Lost events or wrong warning | High; existing fixtures and byte properties | Defer broader prefix corpus | +| Encoding and embedded NUL | Plausible input; defect unconfirmed | Token confusion or output ambiguity | High; length-aware views and unsigned-char ctype casts inspected | Only day-token NUL/high-byte controls added | +| Oversized input | Plausible; no explicit getline/retention cap | Memory pressure from long lines or accumulated results | High for bounded cases; resource policy unspecified | Defer; one 4,096-digit day tested | +| Numeric conversion/narrowing | Confirmed for day conversion | Invalid input can emit an event on an incorrect date | Minimal `Mar 257` witness; deterministic oracle | **Select** | +| Lifetime/ownership | Low source-level signal; not cleared | Potential memory corruption if borrowing escapes | ASan feasible; events own strings | Run sanitizers, no separate lifetime redesign | +| Invalid timestamps | Confirmed day subcase; other cases unconfirmed | Evidence chronology and downstream counts | Independent month lengths and leap-year controls | Same selected conversion boundary | +| Sequential events | Parser config is immutable across calls; detector state is more complex | Recovery/accounting or grouping errors | Three-line parser recovery is deterministic | Verify recovery; leave detector method frozen | + +## Confirmed defect and smallest fix + +`build_timestamp` constructed `chrono::day` from an unchecked `int` and only +then called `year_month_day::ok()`. The tested libstdc++ representation narrows +the day before validation. A syntactically valid integer outside the day domain +can therefore become a valid date. The baseline accepted the `257` witness; +the new test failed immediately. No sanitizer diagnostic was necessary to +detect this semantic error, and no memory-corruption vulnerability is claimed. + +The fix rejects values outside 1..31 before calendar construction. Existing +month/leap validation and error taxonomy remain in use. Valid-input detection +semantics are unchanged; previously accepted invalid day tokens now become +warnings and can no longer contribute events. Consumers that relied on this +invalid-input acceptance will observe different counts. + +## Decision evidence and limits + +- Rejected: relying only on ASan/UBSan. Defined narrowing can still violate a + semantic input contract; a domain oracle is required. +- Rejected: normalizing or clamping invalid days. That invents evidence dates. +- Rejected: changing all integer parsing or adding a global line-size policy. + Neither is needed for the demonstrated day defect. +- No goldens were updated. Existing report, CLI, detector, and research tests + pass without fixture changes; exact commands and results are in + [the execution report](FUZZING-OR-BOUNDARY-TEST-REPORT.md). +- This run covers Linux/GCC and Clang with libstdc++; Windows/MSVC and other standard libraries + were not executed locally. Finite clean sanitizer runs do not prove complete + memory safety or rule out other timestamp defects. Direct API year ranges, + timezone/clock lexical policy, and resource limits remain outside scope. +- Rollback: revert this increment; doing so restores invalid-day acceptance. + No data migration or research-artifact regeneration is involved. +- Suggested next increment: deterministic truncation tests around the source + envelope/program delimiter, including owning-event lifetime checks. Do not + implement automatically or expand the supported grammar. diff --git a/docs/parser-contract.md b/docs/parser-contract.md index 51d5f3c..5c09472 100644 --- a/docs/parser-contract.md +++ b/docs/parser-contract.md @@ -21,6 +21,11 @@ boundary. Supported timezone tokens for `journalctl_short_full` are intentionally narrow: `UTC`, `GMT`, `Z`, and numeric offsets such as `+0000` or `+00:00`. +Day values must be within `1..31` before conversion to a calendar type; +month length and leap-year validity are checked afterward. Out-of-range days +produce `unknown_timestamp` / `invalid calendar date` warnings rather than +wrapping into valid dates. See [the boundary test report](../FUZZING-OR-BOUNDARY-TEST-REPORT.md). + ## Recognized event families The parser currently recognizes common authentication evidence from: diff --git a/fuzz/timestamp_day_fuzz.cpp b/fuzz/timestamp_day_fuzz.cpp new file mode 100644 index 0000000..d6ff9ef --- /dev/null +++ b/fuzz/timestamp_day_fuzz.cpp @@ -0,0 +1,49 @@ +#include "parser.hpp" + +#include +#include +#include +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + // The corpus mutates one token, not the surrounding source envelope. + if (size == 0) { + return 0; + } + const std::string_view token(reinterpret_cast(data), size); + if (token.find_first_of(" \t\r\n\f\v") != std::string_view::npos) { + return 0; + } + int day = 0; + const auto converted = std::from_chars(token.data(), token.data() + token.size(), day); + const bool valid = converted.ec == std::errc{} + && converted.ptr == token.data() + token.size() && day >= 1 && day <= 31; + const std::string line = "Mar " + std::string(token) + + " 08:30:00 example-host sshd[4200]: Failed password for user-a" + " from 203.0.113.10 port 50101 ssh2"; + const loglens::AuthLogParser parser({loglens::InputMode::SyslogLegacy, 2026}); + std::string reason; + auto category = loglens::ParserFailureCategory::MalformedSourceIp; + const auto event = parser.parse_line(line, 1, &reason, &category); + if (event.has_value() != valid) { + std::abort(); + } + if (!valid) { + if (reason.empty() || category != loglens::ParserFailureCategory::UnknownTimestamp) { + std::abort(); + } + return 0; + } + // Only range-checked values reach chrono in the oracle. + const auto expected = std::chrono::sys_days{ + std::chrono::year{2026} / std::chrono::March + / std::chrono::day{static_cast(day)}} + + std::chrono::hours{8} + std::chrono::minutes{30}; + if (event->timestamp != expected || !reason.empty()) { + std::abort(); + } + return 0; +} diff --git a/src/parser/timestamp_parser.cpp b/src/parser/timestamp_parser.cpp index 1cbb84b..e52c0bf 100644 --- a/src/parser/timestamp_parser.cpp +++ b/src/parser/timestamp_parser.cpp @@ -71,6 +71,11 @@ std::optional build_timestamp(int year_value, std::chrono::minutes offset = std::chrono::minutes{0}) { using namespace std::chrono; + // Validate before chrono::day can narrow an out-of-range integer. + if (day_value < 1 || day_value > 31) { + return std::nullopt; + } + const year_month_day date{year{year_value}, month{month_index}, day{static_cast(day_value)}}; if (!date.ok()) { return std::nullopt; diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md index d6e107e..39ee2f3 100644 --- a/tests/fuzz/README.md +++ b/tests/fuzz/README.md @@ -1,5 +1,11 @@ # Parser fuzz harness +For the focused day-token oracle, build the optional `fuzz_timestamp_day` +target using the same configuration. Its three seeds are in +`tests/fuzz/corpus/timestamp_day`; copy them into a writable build-directory +corpus before running. See [the executed boundary/fuzz report](../../FUZZING-OR-BOUNDARY-TEST-REPORT.md) +for exact commands, the confirmed day-narrowing regression, and coverage limits. + The parser fuzz target is optional and requires Clang with libFuzzer support. It exercises both supported input modes and treats these result invariants as crash conditions: diff --git a/tests/fuzz/corpus/timestamp_day/valid_day b/tests/fuzz/corpus/timestamp_day/valid_day new file mode 100644 index 0000000..b74e882 --- /dev/null +++ b/tests/fuzz/corpus/timestamp_day/valid_day @@ -0,0 +1 @@ +31 \ No newline at end of file diff --git a/tests/fuzz/corpus/timestamp_day/wrapped_negative b/tests/fuzz/corpus/timestamp_day/wrapped_negative new file mode 100644 index 0000000..7c0e9b4 --- /dev/null +++ b/tests/fuzz/corpus/timestamp_day/wrapped_negative @@ -0,0 +1 @@ +-255 \ No newline at end of file diff --git a/tests/fuzz/corpus/timestamp_day/wrapped_positive b/tests/fuzz/corpus/timestamp_day/wrapped_positive new file mode 100644 index 0000000..a9d8b73 --- /dev/null +++ b/tests/fuzz/corpus/timestamp_day/wrapped_positive @@ -0,0 +1 @@ +257 \ No newline at end of file diff --git a/tests/test_timestamp_boundaries.cpp b/tests/test_timestamp_boundaries.cpp new file mode 100644 index 0000000..50f9645 --- /dev/null +++ b/tests/test_timestamp_boundaries.cpp @@ -0,0 +1,120 @@ +#include "parser.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +std::string line_for(std::string_view month, std::string_view day) { + return std::string(month) + " " + std::string(day) + + " 08:30:00 example-host sshd[4200]: Failed password for user-a" + " from 203.0.113.10 port 50101 ssh2"; +} + +std::size_t checked = 0; + +void check_day(const loglens::AuthLogParser& parser, std::string_view month, + std::string_view token, int year, unsigned month_number, + int expected_day, std::string_view failure = "invalid calendar date") { + std::string reason = "stale reason"; + auto category = loglens::ParserFailureCategory::MalformedSourceIp; + const auto event = parser.parse_line(line_for(month, token), 17, &reason, &category); + ++checked; + // Do not construct chrono::day from the adversarial token in the oracle. + if (expected_day == 0) { + expect(!event, "invalid day accepted at case " + std::to_string(checked)); + expect(category == loglens::ParserFailureCategory::UnknownTimestamp, + "invalid day must remain timestamp telemetry"); + expect(reason == failure, "unexpected timestamp failure reason"); + return; + } + expect(event.has_value(), "valid day rejected"); + const auto expected = std::chrono::sys_days{ + std::chrono::year{year} / std::chrono::month{month_number} + / std::chrono::day{static_cast(expected_day)}} + + std::chrono::hours{8} + std::chrono::minutes{30}; + expect(event->timestamp == expected, "valid day changed timestamp"); + expect(event->line_number == 17 && reason.empty(), "result metadata changed"); +} + +void test_day_domain() { + const loglens::AuthLogParser parser({loglens::InputMode::SyslogLegacy, 2026}); + // Put the minimal positive wrap witness first for a useful baseline failure. + check_day(parser, "Mar", "257", 2026, 3, 0); + for (int day = -1024; day <= 1024; ++day) { + check_day(parser, "Mar", std::to_string(day), 2026, 3, + day >= 1 && day <= 31 ? day : 0); + } + // Adding storage-width multiples must never alias a valid calendar day. + for (const int shift : {-65536, -256, 256, 65536}) { + for (int day = 1; day <= 31; ++day) { + check_day(parser, "Mar", std::to_string(day + shift), 2026, 3, 0); + } + } + for (const auto& token : std::array{ + "2147483647", "-2147483648", "2147483648", "-2147483649", + "1x", std::string("1\0", 2), std::string("1\xff", 2), std::string(4096, '9')}) { + const bool fits_int = token == "2147483647" || token == "-2147483648"; + check_day(parser, "Mar", token, 2026, 3, 0, + fits_int ? "invalid calendar date" : "invalid day token"); + } + check_day(parser, "Mar", "01", 2026, 3, 1); +} + +void test_calendar_controls() { + constexpr std::array months{ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + constexpr std::array lengths{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + for (const int year : {2024, 2026}) { + const loglens::AuthLogParser parser({loglens::InputMode::SyslogLegacy, year}); + for (std::size_t month = 0; month < months.size(); ++month) { + const int last = lengths[month] + (month == 1 && year == 2024 ? 1 : 0); + for (int day = 1; day <= 31; ++day) { + check_day(parser, months[month], std::to_string(day), year, + static_cast(month + 1), day <= last ? day : 0); + } + } + } +} + +void test_stream_recovery() { + const loglens::AuthLogParser parser({loglens::InputMode::SyslogLegacy, 2026}); + std::istringstream input(line_for("Mar", "1") + '\n' + line_for("Mar", "257") + + '\n' + line_for("Mar", "2") + '\n'); + const auto report = parser.parse_stream(input); + expect(report.events.size() == 2 && report.warnings.size() == 1, + "invalid day must be a warning between two valid events"); + expect(report.events[0].line_number == 1 && report.events[1].line_number == 3 + && report.warnings[0].line_number == 2, "stream provenance changed"); + expect(report.warnings[0].category == loglens::ParserFailureCategory::UnknownTimestamp + && report.warnings[0].reason == "invalid calendar date", "warning taxonomy changed"); + expect(report.quality.total_lines == 3 && report.quality.parsed_lines == 2 + && report.quality.unparsed_lines == 1, "stream accounting changed"); +} + +} // namespace + +int main() { + try { + test_day_domain(); + test_calendar_controls(); + test_stream_recovery(); + std::cout << checked << " day-token cases and one three-line recovery stream passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +}