Compare commits
12 Commits
409de16003
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bd4fe6189 | |||
| 5b4f77a72f | |||
| 1657be7711 | |||
| 50194c72b2 | |||
| 6bf63f1823 | |||
| 081d40c208 | |||
| d6831c5851 | |||
| c2cb64e9a7 | |||
| 2d1cbccc5d | |||
| 44b6b99047 | |||
| 0c8dad9502 | |||
| 7755d8385c |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -4,6 +4,22 @@ All notable changes to `indifferentketchup/codex` are documented here.
|
|||||||
|
|
||||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.0] — 2026-05-01
|
||||||
|
|
||||||
|
Render-time PII redaction utility added on the same calendar day as v0.1.0. Cut as a minor version bump rather than a patch because it adds a new public API surface (`RedactorInterface` plus the per-game implementation), which under semver is a minor change, not a patch. Consumers (notably iblogs) pin to `^0.2.0` to opt into the redactor-aware version.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `RedactorInterface` (`src/Util/RedactorInterface.php`) and `ProjectZomboidRedactor` (`src/Util/ProjectZomboid/ProjectZomboidRedactor.php`) — render-time PII filter that scrubs Steam IDs, player names, and world coordinates from Project Zomboid log content. Three independent toggles default to on. Designed as a string-in/string-out utility so consumers can apply it at any rendering or export step. Documented v1 limitations: in PvP combat lines, only the attacker's name and coords are redacted; victim's name and coords (after `hit`) are deferred to v2. In admin lines, `teleported X to <coords>` coordinates are not redacted in v1.
|
||||||
|
- 65 new test methods across six files under `test/tests/Util/Redactor/` — per-category unit tests, combined / toggle / idempotence matrix, and integration coverage that drives all 11 existing PZ fixtures through the redactor end-to-end. Suite total: 260 tests, 492 assertions.
|
||||||
|
- `docs/superpowers/specs/2026-04-30-redactor-design.md` flipped from "deferred" to "implemented" status. Plan committed at `docs/superpowers/plans/2026-05-01-redactor.md`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- New top-level `src/Util/` directory introduced. The Redactor is its first occupant; future utilities (e.g. tokenising redactor variants) land here.
|
||||||
|
|
||||||
## [0.1.0] — 2026-05-01
|
## [0.1.0] — 2026-05-01
|
||||||
|
|
||||||
First public release. Codex is a generic PHP log parsing and analysis framework with full Project Zomboid server-log support across eight analysers. The Composer package name is `indifferentketchup/codex` (the repository directory and Gitea slug are `ik-codex`; the package name is not).
|
First public release. Codex is a generic PHP log parsing and analysis framework with full Project Zomboid server-log support across eight analysers. The Composer package name is `indifferentketchup/codex` (the repository directory and Gitea slug are `ik-codex`; the package name is not).
|
||||||
@@ -32,8 +48,8 @@ First public release. Codex is a generic PHP log parsing and analysis framework
|
|||||||
|
|
||||||
### Deferred
|
### Deferred
|
||||||
|
|
||||||
- **Codex `Redactor` utility** — design captured in `docs/superpowers/specs/2026-04-30-redactor-design.md`. Not implemented in v0.1.0. iblogs (the downstream consumer) handles upload-time PII filtering for this release; codex itself ships no PII helper. The deferred spec exists so iblogs's privacy story has a referenced design to point at and so a future implementation pass has a clear contract to start from.
|
|
||||||
- **Other game implementations** — `Minecraft`, `Hytale`, and `SevenDaysToDie` are detective-stub-only. Each has a TODO `<Game>Detective` extending base `Detective`; their per-component subdirectories under `Analyser`, `Log`, `Parser`, and `Pattern` contain only `.gitkeep` placeholders. Real implementations land if and when fixtures and demand exist.
|
- **Other game implementations** — `Minecraft`, `Hytale`, and `SevenDaysToDie` are detective-stub-only. Each has a TODO `<Game>Detective` extending base `Detective`; their per-component subdirectories under `Analyser`, `Log`, `Parser`, and `Pattern` contain only `.gitkeep` placeholders. Real implementations land if and when fixtures and demand exist.
|
||||||
- **Packagist publication** — v0.1.0 is consumable via Composer's `vcs` repository entry pointing at the Gitea remote. Pushing to Packagist is a separate decision and is not in scope for this release.
|
- **Packagist publication** — v0.1.0 is consumable via Composer's `vcs` repository entry pointing at the Gitea remote. Pushing to Packagist is a separate decision and is not in scope for this release.
|
||||||
|
|
||||||
|
[0.2.0]: https://git.indifferentketchup.com/indifferentketchup/ik-codex/releases/tag/v0.2.0
|
||||||
[0.1.0]: https://git.indifferentketchup.com/indifferentketchup/ik-codex/releases/tag/v0.1.0
|
[0.1.0]: https://git.indifferentketchup.com/indifferentketchup/ik-codex/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ Analysis of Insight[]
|
|||||||
- **`PatternParser`** is regex-driven. Lines that don't match the LINE regex append to the previous `Entry` — this is the mechanism that handles multi-line records like Java stack traces under an ERROR header.
|
- **`PatternParser`** is regex-driven. Lines that don't match the LINE regex append to the previous `Entry` — this is the mechanism that handles multi-line records like Java stack traces under an ERROR header.
|
||||||
- **`PatternAnalyser`** walks entries, runs each registered insight class's static `getPatterns()` against entry text via `preg_match_all`, and emits coalesced insights (equal insights bump a counter instead of duplicating).
|
- **`PatternAnalyser`** walks entries, runs each registered insight class's static `getPatterns()` against entry text via `preg_match_all`, and emits coalesced insights (equal insights bump a counter instead of duplicating).
|
||||||
- **Custom `Analyser` subclasses** are the right move when analysis needs cross-entry state — pairing events, sliding-window thresholds, comparing consecutive snapshots. `PatternAnalyser` operates per-entry only and can't express those. Phase B.3 (`ConnectionFailureAnalyser`, `ItemDuplicationAnalyser`, `SkillProgressionAnomalyAnalyser`) shows the shape: extend `Analyser`, override `analyse()`, walk `$this->log` once, aggregate, then emit coalesced `Problem`/`Information` insights at the end. Tunable thresholds belong as `public const` constants on the subclass with the rationale in a docblock.
|
- **Custom `Analyser` subclasses** are the right move when analysis needs cross-entry state — pairing events, sliding-window thresholds, comparing consecutive snapshots. `PatternAnalyser` operates per-entry only and can't express those. Phase B.3 (`ConnectionFailureAnalyser`, `ItemDuplicationAnalyser`, `SkillProgressionAnomalyAnalyser`) shows the shape: extend `Analyser`, override `analyse()`, walk `$this->log` once, aggregate, then emit coalesced `Problem`/`Information` insights at the end. Tunable thresholds belong as `public const` constants on the subclass with the rationale in a docblock.
|
||||||
|
- **`RedactorInterface`** is a render-time PII filter — string-in/string-out, configured per game, implemented at `src/Util/<Game>/<Game>Redactor.php`. Consumers call `redact(string $content): string` on a concrete instance before rendering or exporting log content.
|
||||||
- Detectors available out of the box: `SinglePatternDetector`, `WeightedSinglePatternDetector`, `LinePatternDetector` (returns match ratio), `MultiPatternDetector` (AND), and the path-based `FilenameDetector` (uses `LogFileInterface::getPath()`, returns `false` when no path is available).
|
- Detectors available out of the box: `SinglePatternDetector`, `WeightedSinglePatternDetector`, `LinePatternDetector` (returns match ratio), `MultiPatternDetector` (AND), and the path-based `FilenameDetector` (uses `LogFileInterface::getPath()`, returns `false` when no path is available).
|
||||||
|
|
||||||
## Game subtrees
|
## Game subtrees
|
||||||
@@ -58,10 +59,13 @@ Layout is **components-outer with game suffix**, not games-outer:
|
|||||||
```
|
```
|
||||||
src/<Component>/<Game>/... e.g. src/Log/ProjectZomboid/ProjectZomboidServerLog.php
|
src/<Component>/<Game>/... e.g. src/Log/ProjectZomboid/ProjectZomboidServerLog.php
|
||||||
src/Pattern/<Game>/<Type>Pattern.php (regex string constants; not a framework abstraction)
|
src/Pattern/<Game>/<Type>Pattern.php (regex string constants; not a framework abstraction)
|
||||||
|
src/Util/<Game>/... e.g. src/Util/ProjectZomboid/ProjectZomboidRedactor.php
|
||||||
test/tests/Games/<Game>/...
|
test/tests/Games/<Game>/...
|
||||||
test/src/Games/<Game>/fixtures/<type>-minimal.txt (synthetic fixtures only)
|
test/src/Games/<Game>/fixtures/<type>-minimal.txt (synthetic fixtures only)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`src/Util/` is the sixth top-level component directory, introduced post-v0.1.0-tag. Its first occupant is the Redactor; future game-agnostic utilities (tokenising redactor variants, etc.) land here too.
|
||||||
|
|
||||||
Scaffolded games: `Minecraft`, `Hytale`, `SevenDaysToDie` (stubs only — empty `.gitkeep`s plus a TODO `<Game>Detective` extending base `Detective`). `ProjectZomboid` is fully implemented: 11 log subclasses, 11 pattern classes, detective wired with all 11, synthetic fixtures, dispatch tests, plus the analyser surface — 11 `PatternAnalyser`-driven Insight classes under `src/Analysis/ProjectZomboid/` and 3 custom `Analyser` subclasses under `src/Analyser/ProjectZomboid/` for cross-entry / threshold logic.
|
Scaffolded games: `Minecraft`, `Hytale`, `SevenDaysToDie` (stubs only — empty `.gitkeep`s plus a TODO `<Game>Detective` extending base `Detective`). `ProjectZomboid` is fully implemented: 11 log subclasses, 11 pattern classes, detective wired with all 11, synthetic fixtures, dispatch tests, plus the analyser surface — 11 `PatternAnalyser`-driven Insight classes under `src/Analysis/ProjectZomboid/` and 3 custom `Analyser` subclasses under `src/Analyser/ProjectZomboid/` for cross-entry / threshold logic.
|
||||||
|
|
||||||
`src/Pattern/` is **not a framework abstraction** — patterns are plain `string` class constants. Each `<Type>Pattern` typically holds a `LINE` constant for the parser plus named-group extractor constants (`FIELDS`, `COMBAT`, `MOD_LOAD`, etc.) for analysers.
|
`src/Pattern/` is **not a framework abstraction** — patterns are plain `string` class constants. Each `<Type>Pattern` typically holds a `LINE` constant for the parser plus named-group extractor constants (`FIELDS`, `COMBAT`, `MOD_LOAD`, etc.) for analysers.
|
||||||
@@ -74,6 +78,7 @@ Scaffolded games: `Minecraft`, `Hytale`, `SevenDaysToDie` (stubs only — empty
|
|||||||
- A custom `Analyser` subclass (cross-entry logic): `UserLog → ConnectionFailureAnalyser`, `ItemLog → ItemDuplicationAnalyser`, `PerkLog → SkillProgressionAnomalyAnalyser`.
|
- A custom `Analyser` subclass (cross-entry logic): `UserLog → ConnectionFailureAnalyser`, `ItemLog → ItemDuplicationAnalyser`, `PerkLog → SkillProgressionAnomalyAnalyser`.
|
||||||
- A configured `PatternAnalyser` (per-entry pattern matching): `ServerLog`, `PvpLog`, `AdminLog` register their respective Insight classes.
|
- A configured `PatternAnalyser` (per-entry pattern matching): `ServerLog`, `PvpLog`, `AdminLog` register their respective Insight classes.
|
||||||
- An empty `PatternAnalyser` for logs with no analysers yet: `ChatLog`, `ClientActionLog`, `CmdLog`, `MapLog`, `BurdJournalsLog`. These are wiring stubs awaiting future analysis work.
|
- An empty `PatternAnalyser` for logs with no analysers yet: `ChatLog`, `ClientActionLog`, `CmdLog`, `MapLog`, `BurdJournalsLog`. These are wiring stubs awaiting future analysis work.
|
||||||
|
- **`ProjectZomboidRedactor`** at `src/Util/ProjectZomboid/ProjectZomboidRedactor.php` — concrete `RedactorInterface` implementation. Downstream consumers call `redact(string): string` to scrub Steam IDs (zeroed placeholder), player names (`<player>`), and world coordinates (`0,0,0`) from log content. Three independent toggle methods default to on: `redactSteamIds(bool)`, `redactPlayerNames(bool)`, `redactCoordinates(bool)`. Pass order (Steam ID → player name → coords) is mandatory and enforced internally — see Pitfall 5.
|
||||||
|
|
||||||
### Standard test template for a Log subclass
|
### Standard test template for a Log subclass
|
||||||
|
|
||||||
@@ -85,6 +90,7 @@ At minimum: (1) entry count after `parse()` matches the synthetic fixture's line
|
|||||||
2. **PHPUnit 12 requires the `#[DataProvider('methodName')]` attribute.** The legacy `@dataProvider` annotation silently passes zero args and fails with `ArgumentCountError`.
|
2. **PHPUnit 12 requires the `#[DataProvider('methodName')]` attribute.** The legacy `@dataProvider` annotation silently passes zero args and fails with `ArgumentCountError`.
|
||||||
3. **`Level::fromString()` defaults to `Level::INFO` for unknown tokens.** Project Zomboid log levels map: `LOG`/`INFO` → INFO; `WARN` → WARNING; `ERROR` → ERROR.
|
3. **`Level::fromString()` defaults to `Level::INFO` for unknown tokens.** Project Zomboid log levels map: `LOG`/`INFO` → INFO; `WARN` → WARNING; `ERROR` → ERROR.
|
||||||
4. **`PatternParser` matches array** must declare a match-type for **every** capture group in the regex (`TIME`, `LEVEL`, or `PREFIX`); otherwise the parser throws on the unmapped index. Use non-capturing groups `(?:...)` for fields you want to skip.
|
4. **`PatternParser` matches array** must declare a match-type for **every** capture group in the regex (`TIME`, `LEVEL`, or `PREFIX`); otherwise the parser throws on the unmapped index. Use non-capturing groups `(?:...)` for fields you want to skip.
|
||||||
|
5. **`ProjectZomboidRedactor` pass order is mandatory.** `PLAYER_AFTER_STEAMID_REGEX` anchors on the already-redacted Steam ID placeholder — it will not match raw Steam IDs. Do NOT swap the Steam ID and player-name passes, and do NOT stub out the Steam ID pass while leaving the player-name pass enabled.
|
||||||
|
|
||||||
## Workflow conventions
|
## Workflow conventions
|
||||||
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -59,6 +59,21 @@ Project Zomboid Debug Server Log
|
|||||||
|
|
||||||
If the log content arrives without a filesystem path (clipboard paste, web upload, stream), use `StringLogFile` or `StreamLogFile` instead of `PathLogFile`. The detective falls back to content signatures when the filename hint is absent.
|
If the log content arrives without a filesystem path (clipboard paste, web upload, stream), use `StringLogFile` or `StreamLogFile` instead of `PathLogFile`. The detective falls back to content signatures when the filename hint is absent.
|
||||||
|
|
||||||
|
## Redaction
|
||||||
|
|
||||||
|
Before rendering or exporting log content, pass it through `ProjectZomboidRedactor` to strip PII:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use IndifferentKetchup\Codex\Util\ProjectZomboid\ProjectZomboidRedactor;
|
||||||
|
|
||||||
|
$redactor = new ProjectZomboidRedactor();
|
||||||
|
$safe = $redactor->redact($logContent);
|
||||||
|
```
|
||||||
|
|
||||||
|
This scrubs three categories in a fixed pass order: Steam IDs are replaced with a zeroed placeholder, player names with `<player>`, and world coordinates with `0,0,0`. All three passes are on by default; opt out per category with `redactSteamIds(bool)`, `redactPlayerNames(bool)`, or `redactCoordinates(bool)`.
|
||||||
|
|
||||||
|
Documented v1 limitations: in PvP combat lines, only the attacker's name and coords are redacted — the victim's name and coords (appearing after `hit`) are deferred to v2. In admin lines, `teleported X to <coords>` coordinates are not redacted in v1.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Codex Redactor utility — design spec
|
# Codex Redactor utility — design spec
|
||||||
|
|
||||||
> Retroactive: written 2026-05-01.
|
> Retroactive: written 2026-05-01.
|
||||||
> **Status: deferred — not implemented.** This is a forward-looking design captured here for backfill symmetry and to inform iblogs's upload-time PII handling.
|
> **Status: implemented on the `redactor` branch (2026-05-01).** Plan: `docs/superpowers/plans/2026-05-01-redactor.md`. Arrival commit set documented in `CHANGELOG.md` `[Unreleased]`. The "Status: deferred" framing below is preserved for historical context; treat this file as the as-built design contract.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
|||||||
123
src/Util/ProjectZomboid/ProjectZomboidRedactor.php
Normal file
123
src/Util/ProjectZomboid/ProjectZomboidRedactor.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IndifferentKetchup\Codex\Util\ProjectZomboid;
|
||||||
|
|
||||||
|
use IndifferentKetchup\Codex\Util\RedactorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render-time PII filter for Project Zomboid log content.
|
||||||
|
*
|
||||||
|
* Applies up to three sequential regex passes over the raw log string,
|
||||||
|
* each controlled by a boolean toggle (all enabled by default):
|
||||||
|
*
|
||||||
|
* 1. Steam ID pass — replaces 17-digit Steam IDs with a placeholder token.
|
||||||
|
* 2. Player name pass — replaces player display names with a placeholder
|
||||||
|
* token. This pass anchors on the already-redacted Steam ID token, so
|
||||||
|
* the ordering Steam ID -> name -> coordinates is mandatory.
|
||||||
|
* 3. Coordinates pass — replaces world coordinate triplets with a placeholder
|
||||||
|
* token.
|
||||||
|
*
|
||||||
|
* All regex passes use the /u flag for Unicode safety.
|
||||||
|
*
|
||||||
|
* Replacements are not reversible; do not apply to content that must later be
|
||||||
|
* restored to its original form.
|
||||||
|
*/
|
||||||
|
class ProjectZomboidRedactor implements RedactorInterface
|
||||||
|
{
|
||||||
|
/** Regex matching a 17-digit SteamID64 anchored on the 76561198 universe prefix, with lookaround boundaries that reject embedded occurrences. */
|
||||||
|
public const string STEAM_ID_REGEX = '/(?<![A-Za-z0-9])76561198\d{9}(?![A-Za-z0-9])/u';
|
||||||
|
|
||||||
|
/** Zeroed-out SteamID64 placeholder; syntactically valid but refers to no real account. */
|
||||||
|
public const string STEAM_ID_REPLACEMENT = '76561198000000000';
|
||||||
|
|
||||||
|
/** Generic placeholder substituted for every matched player display name. */
|
||||||
|
public const string PLAYER_NAME_REPLACEMENT = '<player>';
|
||||||
|
|
||||||
|
/** Matches a double-quoted player name that immediately follows the redacted Steam ID placeholder (cmd.txt / admin.txt shape); relies on the Steam ID pass having run first. */
|
||||||
|
public const string PLAYER_AFTER_STEAMID_REGEX = '/(?<=76561198000000000) "(?<name>[^"]+)"/u';
|
||||||
|
|
||||||
|
/** Matches the author value inside a ChatMessage{...} envelope, using a fixed-length lookbehind on ", author='" and a lookahead on the closing "'" so only the bare name is replaced. */
|
||||||
|
public const string PLAYER_IN_CHATMESSAGE_REGEX = '/(?<=, author=\')(?<name>[^\']+)(?=\')/u';
|
||||||
|
|
||||||
|
/** Matches the first double-quoted player name following a Combat: or Safety: subsystem token (pvp.txt shape); does NOT redact the second name after "hit" — deferred to v2. */
|
||||||
|
public const string PLAYER_IN_PVP_SUBSYSTEM_REGEX = '/(?<=(?:Combat|Safety): )"(?<name>[^"]+)"/u';
|
||||||
|
|
||||||
|
/** Zeroed-out coordinate triple used as the inner replacement; bracket/paren/`at` wrapper is preserved by the regex lookaround anchors. */
|
||||||
|
public const string COORDS_REPLACEMENT = '0,0,0';
|
||||||
|
|
||||||
|
/** Matches integer or float coordinate triplets that immediately follow the literal ` at ` token (map.txt / item.txt shape); the trailing dot is preserved via lookahead. */
|
||||||
|
public const string COORDS_AT_CLAUSE_REGEX = '/(?<= at )(?<x>[\d.]+),(?<y>[\d.]+),(?<z>-?[\d.]+)(?=\.)/u';
|
||||||
|
|
||||||
|
/** Matches integer coordinate triplets enclosed in square brackets (ClientActionLog.txt / PerkLog.txt / cmd.txt @-context shape); the surrounding brackets are preserved via lookaround. */
|
||||||
|
public const string COORDS_BRACKETED_REGEX = '/(?<=\[)(?<x>\d+),(?<y>\d+),(?<z>-?\d+)(?=\])/u';
|
||||||
|
|
||||||
|
/** Matches integer coordinate triplets enclosed in round parentheses, anchored on a trailing PvP verb to disambiguate from server-metadata triples (pvp.txt Combat:/Safety: shape); only the attacker/first-coord set is redacted per line — the victim coords lack the trailing keyword and are deferred to v2. */
|
||||||
|
public const string COORDS_PARENTHESISED_REGEX = '/(?<=\()(?<x>\d+),(?<y>\d+),(?<z>-?\d+)(?=\) (?:hit|restore|store|true|false))/u';
|
||||||
|
|
||||||
|
private bool $redactSteamIds = true;
|
||||||
|
private bool $redactPlayerNames = true;
|
||||||
|
private bool $redactCoordinates = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable the Steam ID redaction pass.
|
||||||
|
*
|
||||||
|
* @param bool $on Pass true to enable, false to disable.
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function redactSteamIds(bool $on): static
|
||||||
|
{
|
||||||
|
$this->redactSteamIds = $on;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable the player-name redaction pass.
|
||||||
|
*
|
||||||
|
* @param bool $on Pass true to enable, false to disable.
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function redactPlayerNames(bool $on): static
|
||||||
|
{
|
||||||
|
$this->redactPlayerNames = $on;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable the coordinates redaction pass.
|
||||||
|
*
|
||||||
|
* @param bool $on Pass true to enable, false to disable.
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public function redactCoordinates(bool $on): static
|
||||||
|
{
|
||||||
|
$this->redactCoordinates = $on;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact PII from the given Project Zomboid log content.
|
||||||
|
*
|
||||||
|
* Passes are applied in the mandatory order: Steam ID -> player name ->
|
||||||
|
* coordinates. See class docblock for rationale.
|
||||||
|
*
|
||||||
|
* @param string $content Raw log content that may contain PII.
|
||||||
|
* @return string Content with enabled PII categories replaced by tokens.
|
||||||
|
*/
|
||||||
|
public function redact(string $content): string
|
||||||
|
{
|
||||||
|
if ($this->redactSteamIds) {
|
||||||
|
$content = preg_replace(self::STEAM_ID_REGEX, self::STEAM_ID_REPLACEMENT, $content);
|
||||||
|
}
|
||||||
|
if ($this->redactPlayerNames) {
|
||||||
|
$content = preg_replace(self::PLAYER_AFTER_STEAMID_REGEX, ' "' . self::PLAYER_NAME_REPLACEMENT . '"', $content);
|
||||||
|
$content = preg_replace(self::PLAYER_IN_CHATMESSAGE_REGEX, self::PLAYER_NAME_REPLACEMENT, $content);
|
||||||
|
$content = preg_replace(self::PLAYER_IN_PVP_SUBSYSTEM_REGEX, '"' . self::PLAYER_NAME_REPLACEMENT . '"', $content);
|
||||||
|
}
|
||||||
|
if ($this->redactCoordinates) {
|
||||||
|
$content = preg_replace(self::COORDS_AT_CLAUSE_REGEX, self::COORDS_REPLACEMENT, $content);
|
||||||
|
$content = preg_replace(self::COORDS_BRACKETED_REGEX, self::COORDS_REPLACEMENT, $content);
|
||||||
|
$content = preg_replace(self::COORDS_PARENTHESISED_REGEX, self::COORDS_REPLACEMENT, $content);
|
||||||
|
}
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Util/RedactorInterface.php
Normal file
20
src/Util/RedactorInterface.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IndifferentKetchup\Codex\Util;
|
||||||
|
|
||||||
|
interface RedactorInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Redact PII from the given content string and return the result.
|
||||||
|
*
|
||||||
|
* The method is stateless from the caller's perspective: the same instance
|
||||||
|
* may be called repeatedly and each call operates independently on its
|
||||||
|
* input. Configuration (which passes are enabled, replacement tokens, etc.)
|
||||||
|
* is applied once via implementation-specific setters before the first call
|
||||||
|
* to redact().
|
||||||
|
*
|
||||||
|
* @param string $content Raw log content that may contain PII.
|
||||||
|
* @return string Content with PII replaced by redaction tokens.
|
||||||
|
*/
|
||||||
|
public function redact(string $content): string;
|
||||||
|
}
|
||||||
146
test/tests/Util/Redactor/ProjectZomboidRedactorCombinedTest.php
Normal file
146
test/tests/Util/Redactor/ProjectZomboidRedactorCombinedTest.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IndifferentKetchup\Codex\Test\Tests\Util\Redactor;
|
||||||
|
|
||||||
|
use IndifferentKetchup\Codex\Util\ProjectZomboid\ProjectZomboidRedactor;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ProjectZomboidRedactorCombinedTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testFullScrubAllTogglesOn(): void
|
||||||
|
{
|
||||||
|
// Realistic multi-line input touching all three PII categories:
|
||||||
|
// Steam IDs, player names in multiple contexts (after Steam ID, in ChatMessage,
|
||||||
|
// after Combat:/Safety:), and coordinates in multiple shapes (at clause,
|
||||||
|
// bracketed, parenthesised before PvP verb).
|
||||||
|
$input = implode("\n", [
|
||||||
|
// cmd.txt / admin.txt: Steam ID + quoted name + at-clause coords (keyword " at ")
|
||||||
|
'[16-04-26 12:00:00.000] 76561198111111111 "Player1" added Base.Aerosolbomb at 1000,2000,0.',
|
||||||
|
// map.txt: Steam ID + quoted name + at-clause float coords
|
||||||
|
'[16-04-26 12:00:01.000] 76561198222222222 "Player2" added IsoObject (fence_01) at 1050.0,2050.0,0.0.',
|
||||||
|
// chat.txt: ChatMessage author
|
||||||
|
"[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='AdminUser', text='hello'}.",
|
||||||
|
// pvp.txt Combat: name + attacker parenthesised coords before "hit"
|
||||||
|
'[16-04-26 17:14:35.128][INFO] Combat: "Player1" (1005,2005,0) hit "Player2" (1006,2005,0) weapon="Tire Iron (Worn)" damage=0.112317.',
|
||||||
|
// pvp.txt Safety: name + parenthesised coords before "restore"
|
||||||
|
'[16-04-26 16:17:49.731][LOG] Safety: "Player1" (1000,2000,0) restore true.',
|
||||||
|
// ClientActionLog: bracketed Steam ID + action + name + coords bracket
|
||||||
|
'[16-04-26 12:00:02.000] [76561198333333333][ISEnterVehicle][Player2][1020,2020,0][Van_LectroMax].',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expected = implode("\n", [
|
||||||
|
'[16-04-26 12:00:00.000] 76561198000000000 "<player>" added Base.Aerosolbomb at 0,0,0.',
|
||||||
|
'[16-04-26 12:00:01.000] 76561198000000000 "<player>" added IsoObject (fence_01) at 0,0,0.',
|
||||||
|
"[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='<player>', text='hello'}.",
|
||||||
|
'[16-04-26 17:14:35.128][INFO] Combat: "<player>" (0,0,0) hit "Player2" (1006,2005,0) weapon="Tire Iron (Worn)" damage=0.112317.',
|
||||||
|
'[16-04-26 16:17:49.731][LOG] Safety: "<player>" (0,0,0) restore true.',
|
||||||
|
'[16-04-26 12:00:02.000] [76561198000000000][ISEnterVehicle][Player2][0,0,0][Van_LectroMax].',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'With all three toggles on, every Steam ID, player name context, and coord shape must be replaced.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSteamIdToggleOffLeavesSteamIdsIntact(): void
|
||||||
|
{
|
||||||
|
// All three PII categories present; Steam ID toggle is disabled.
|
||||||
|
//
|
||||||
|
// Important nuance: PLAYER_AFTER_STEAMID_REGEX anchors on the redacted placeholder
|
||||||
|
// 76561198000000000. With redactSteamIds(false) the raw Steam ID survives, so the
|
||||||
|
// regex does NOT fire for lines in the "after-Steam-ID" shape — those names survive
|
||||||
|
// too. Names anchored by other contexts (ChatMessage author, Combat:/Safety:) are
|
||||||
|
// still redacted because those regexes don't depend on the Steam ID pass.
|
||||||
|
$input = implode("\n", [
|
||||||
|
// after-Steam-ID shape: name will NOT be redacted because the Steam ID is raw
|
||||||
|
'[16-04-26 12:00:00.000] 76561198111111111 "Player1" added Base.Aerosolbomb at 1000,2000,0.',
|
||||||
|
// ChatMessage author: still redacted (anchor is independent of Steam ID pass)
|
||||||
|
"[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='AdminUser', text='hello'}.",
|
||||||
|
// Combat: name + attacker coords
|
||||||
|
'[16-04-26 17:14:35.128][INFO] Combat: "Player2" (1005,2005,0) hit "Player1" (1006,2005,0) weapon="Pipe Bomb" damage=1.0.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expected = implode("\n", [
|
||||||
|
// Steam ID intact; "Player1" NOT redacted (anchor regex didn't fire)
|
||||||
|
'[16-04-26 12:00:00.000] 76561198111111111 "Player1" added Base.Aerosolbomb at 0,0,0.',
|
||||||
|
// ChatMessage name redacted; coords were an at-clause → redacted
|
||||||
|
"[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='<player>', text='hello'}.",
|
||||||
|
// Combat: name + attacker coords both redacted
|
||||||
|
'[16-04-26 17:14:35.128][INFO] Combat: "<player>" (0,0,0) hit "Player1" (1006,2005,0) weapon="Pipe Bomb" damage=1.0.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$expected,
|
||||||
|
$output,
|
||||||
|
'With Steam ID toggle off: raw Steam IDs survive; PLAYER_AFTER_STEAMID_REGEX does not fire (no placeholder to anchor on) so those names also survive; ChatMessage and Combat:/Safety: names are still redacted; coords are still redacted.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlayerNameToggleOffLeavesNamesIntact(): void
|
||||||
|
{
|
||||||
|
// Steam IDs and coords redact; player names survive verbatim.
|
||||||
|
$input = implode("\n", [
|
||||||
|
'[16-04-26 12:00:00.000] 76561198111111111 "Player1" added Base.Aerosolbomb at 1000,2000,0.',
|
||||||
|
"[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='Player2', text='bye'}.",
|
||||||
|
'[16-04-26 16:17:49.731][LOG] Safety: "AdminUser" (1050,2050,0) restore true.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expected = implode("\n", [
|
||||||
|
'[16-04-26 12:00:00.000] 76561198000000000 "Player1" added Base.Aerosolbomb at 0,0,0.',
|
||||||
|
"[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='Player2', text='bye'}.",
|
||||||
|
'[16-04-26 16:17:49.731][LOG] Safety: "AdminUser" (0,0,0) restore true.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactPlayerNames(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'With player-name toggle off, all player names must survive; Steam IDs and coords must still be redacted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCoordinatesToggleOffLeavesCoordsIntact(): void
|
||||||
|
{
|
||||||
|
// Steam IDs and player names redact; coordinates survive verbatim.
|
||||||
|
$input = implode("\n", [
|
||||||
|
'[16-04-26 12:00:00.000] 76561198111111111 "Player1" added Base.Aerosolbomb at 1000,2000,0.',
|
||||||
|
'[16-04-26 12:00:01.000] [76561198222222222][ISEnterVehicle][Player2][1020,2020,0][Van_LectroMax].',
|
||||||
|
'[16-04-26 17:14:35.128][INFO] Combat: "AdminUser" (1005,2005,0) hit "Player1" (1006,2005,0) weapon="Baseball Bat" damage=0.5.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expected = implode("\n", [
|
||||||
|
'[16-04-26 12:00:00.000] 76561198000000000 "<player>" added Base.Aerosolbomb at 1000,2000,0.',
|
||||||
|
'[16-04-26 12:00:01.000] [76561198000000000][ISEnterVehicle][Player2][1020,2020,0][Van_LectroMax].',
|
||||||
|
'[16-04-26 17:14:35.128][INFO] Combat: "<player>" (1005,2005,0) hit "Player1" (1006,2005,0) weapon="Baseball Bat" damage=0.5.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactCoordinates(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'With coordinates toggle off, all coord triplets must survive; Steam IDs and player names must still be redacted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllTogglesOffReturnsInputByteForByte(): void
|
||||||
|
{
|
||||||
|
// Disabling every toggle must produce an output identical to the input —
|
||||||
|
// the "passthrough" contract: opt-out means truly nothing happens.
|
||||||
|
$input = implode("\n", [
|
||||||
|
'[16-04-26 12:00:00.000] 76561198111111111 "Player1" added Base.Aerosolbomb at 1000,2000,0.',
|
||||||
|
"[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='Player2', text='hello'}.",
|
||||||
|
'[16-04-26 17:14:35.128][INFO] Combat: "AdminUser" (1005,2005,0) hit "Player1" (1006,2005,0) weapon="Tire Iron (Worn)" damage=0.112317.',
|
||||||
|
'[16-04-26 12:00:01.000] [76561198333333333][ISEnterVehicle][Player2][1020,2020,0][Van_LectroMax].',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redactPlayerNames(false)
|
||||||
|
->redactCoordinates(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($input, $output, 'With all three toggles disabled, the output must be byte-for-byte identical to the input.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IndifferentKetchup\Codex\Test\Tests\Util\Redactor;
|
||||||
|
|
||||||
|
use IndifferentKetchup\Codex\Util\ProjectZomboid\ProjectZomboidRedactor;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ProjectZomboidRedactorCoordinatesTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testRedactsAtClauseCoords(): void
|
||||||
|
{
|
||||||
|
// map.txt / item.txt shape: integer coords following " at " with trailing dot.
|
||||||
|
$input = '[16-04-26 12:00:00.000] 76561198000000001 "Player1" added Base.Aerosolbomb at 1000,2000,0.';
|
||||||
|
$expected = '[16-04-26 12:00:00.000] 76561198000000001 "Player1" added Base.Aerosolbomb at 0,0,0.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redactPlayerNames(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'Integer coords following " at " must be replaced; leading "at " and trailing "." must be preserved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedactsAtClauseFloatCoords(): void
|
||||||
|
{
|
||||||
|
// map.txt shape: IsoObject form with float coords (x.x,y.y,z.z).
|
||||||
|
$input = '[16-04-26 12:00:01.000] 76561198000000001 "Player1" added IsoObject (fencing_damaged_01_124) at 1010.0,2010.0,0.0.';
|
||||||
|
$expected = '[16-04-26 12:00:01.000] 76561198000000001 "Player1" added IsoObject (fencing_damaged_01_124) at 0,0,0.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redactPlayerNames(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'Float coords following " at " must be replaced; the IsoObject parenthesised form must be unaffected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedactsBracketedCoords(): void
|
||||||
|
{
|
||||||
|
// ClientActionLog.txt shape: strict 5-field bracketed structure.
|
||||||
|
// The Steam ID bracket and action/player/param brackets must survive.
|
||||||
|
$input = '[16-04-26 12:00:02.000] [76561198000000001][ISEnterVehicle][Player1][1000,2000,0][Van_LectroMax].';
|
||||||
|
$expected = '[16-04-26 12:00:02.000] [76561198000000001][ISEnterVehicle][Player1][0,0,0][Van_LectroMax].';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redactPlayerNames(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'Coord bracket must become [0,0,0]; Steam ID, action, player name, and param brackets must be unaffected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedactsBracketedNegativeZ(): void
|
||||||
|
{
|
||||||
|
// Basement Z coordinates are negative; the regex must handle the leading minus.
|
||||||
|
$input = '[16-04-26 12:00:03.000] [76561198000000001][ISEnterVehicle][Player1][1020,2020,-1][Van_LectroMax].';
|
||||||
|
$expected = '[16-04-26 12:00:03.000] [76561198000000001][ISEnterVehicle][Player1][0,0,0][Van_LectroMax].';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redactPlayerNames(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'Negative Z (basement level) inside square brackets must be replaced.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedactsParenthesisedCoordsBeforeHit(): void
|
||||||
|
{
|
||||||
|
// pvp.txt Combat: shape. The attacker coords are followed by ") hit" and ARE
|
||||||
|
// redacted. The victim coords are followed by ") weapon=" and are NOT redacted
|
||||||
|
// in v1 — the trailing-keyword anchor is intentionally absent for that position.
|
||||||
|
$input = '[16-04-26 17:14:35.128][INFO] Combat: "Player1" (1005,2005,0) hit "Player2" (1006,2005,0) weapon="Tire Iron (Worn)" damage=0.112317.';
|
||||||
|
$expected = '[16-04-26 17:14:35.128][INFO] Combat: "Player1" (0,0,0) hit "Player2" (1006,2005,0) weapon="Tire Iron (Worn)" damage=0.112317.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redactPlayerNames(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
// Attacker coords (before "hit") are redacted; victim coords (before "weapon=") are NOT — deferred to v2.
|
||||||
|
$this->assertSame($expected, $output, 'Attacker coords before "hit" must be replaced; victim coords without a trailing keyword must survive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedactsParenthesisedCoordsBeforeSafetyVerb(): void
|
||||||
|
{
|
||||||
|
// pvp.txt Safety: shape; coords followed by ") restore true".
|
||||||
|
$input = '[16-04-26 16:17:49.731][LOG] Safety: "Player1" (1000,2000,0) restore true.';
|
||||||
|
$expected = '[16-04-26 16:17:49.731][LOG] Safety: "Player1" (0,0,0) restore true.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redactPlayerNames(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'Coords followed by ") restore" must be replaced.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServerMetadataTriplesAreNotRedacted(): void
|
||||||
|
{
|
||||||
|
// DebugLog-server.txt entries contain server-state metadata that superficially
|
||||||
|
// resembles coordinates but is not: "st:48,648,157,584" is a 4-component token,
|
||||||
|
// "t:1776297642406" is a millisecond timestamp. Neither pattern lives inside
|
||||||
|
// brackets, parentheses followed by a PvP verb, or after " at " — so none of
|
||||||
|
// the three coordinate regexes should fire.
|
||||||
|
$input = '[16-04-26 00:01:19.080] ERROR: General f:0, t:1776297642406, st:48,648,157,584> Server starting up.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($input, $output, 'Server metadata triples (st:) and millisecond timestamps (t:) must pass through unchanged.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToggleOffLeavesCoordsIntact(): void
|
||||||
|
{
|
||||||
|
$input = '[16-04-26 12:00:04.000] 76561198000000001 "Player1" added Base.Aerosolbomb at 1000,2000,0.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redactPlayerNames(false)
|
||||||
|
->redactCoordinates(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($input, $output, 'With the coordinates toggle disabled the original input must be returned unchanged.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IndifferentKetchup\Codex\Test\Tests\Util\Redactor;
|
||||||
|
|
||||||
|
use IndifferentKetchup\Codex\Util\ProjectZomboid\ProjectZomboidRedactor;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the idempotence property of ProjectZomboidRedactor::redact().
|
||||||
|
*
|
||||||
|
* Idempotence: redact(redact(x)) === redact(x) for all valid inputs.
|
||||||
|
*
|
||||||
|
* A downstream consumer might accidentally double-pipe content through the
|
||||||
|
* Redactor. The result must be stable — a second pass must make no further
|
||||||
|
* changes. If a regex were poorly anchored such that the post-redact placeholder
|
||||||
|
* itself matched and was re-redacted to something different, idempotence would
|
||||||
|
* fail. Specifically, the player-name regex PLAYER_AFTER_STEAMID_REGEX anchors
|
||||||
|
* on 76561198000000000 — the same value the Steam ID pass writes. This test
|
||||||
|
* suite verifies that applying redact() twice is safe: on the second pass, names
|
||||||
|
* already written as <player> do not accidentally re-match and produce a doubly-
|
||||||
|
* nested result like "<player>" → something else.
|
||||||
|
*/
|
||||||
|
class ProjectZomboidRedactorIdempotenceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testIdempotenceSteamIdOnly(): void
|
||||||
|
{
|
||||||
|
$input = implode("\n", [
|
||||||
|
'Players: 76561198111111111, 76561198222222222, 76561198333333333 connected.',
|
||||||
|
'[16-04-26 12:00:00.000] [76561198111111111][ISEnterVehicle][Player1][1000,2000,0][Van_LectroMax].',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$redactor = new ProjectZomboidRedactor();
|
||||||
|
$redacted = $redactor->redact($input);
|
||||||
|
$redactedAgain = $redactor->redact($redacted);
|
||||||
|
|
||||||
|
$this->assertSame($redacted, $redactedAgain, 'Applying redact() twice to Steam-ID-only input must produce the same result as applying it once.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIdempotencePlayerNamesOnly(): void
|
||||||
|
{
|
||||||
|
// Input already has the Steam ID placeholder in place (as the Steam ID pass
|
||||||
|
// would have written it), so PLAYER_AFTER_STEAMID_REGEX can fire. After the
|
||||||
|
// first pass the name becomes "<player>"; the second pass must leave "<player>"
|
||||||
|
// untouched — it is not a valid display name inside double quotes preceded
|
||||||
|
// by the Steam ID placeholder anchor in a way that would re-match, because
|
||||||
|
// the replacement written is: 76561198000000000 "<player>", and the regex
|
||||||
|
// would need an unquoted player name inside quotes after the placeholder.
|
||||||
|
// "<player>" (with the angle brackets) does satisfy [^"]+ but the second
|
||||||
|
// pass must still produce an identical result.
|
||||||
|
$input = implode("\n", [
|
||||||
|
'76561198000000000 "Player1" ISLogSystem.writeLog @ 1000,2000,0.',
|
||||||
|
"[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='AdminUser', text='hi'}.",
|
||||||
|
'[16-04-26 16:17:49.731][LOG] Safety: "Player2" (1000,2000,0) restore true.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$redactor = (new ProjectZomboidRedactor())->redactSteamIds(false)->redactCoordinates(false);
|
||||||
|
$redacted = $redactor->redact($input);
|
||||||
|
$redactedAgain = $redactor->redact($redacted);
|
||||||
|
|
||||||
|
$this->assertSame($redacted, $redactedAgain, 'Applying redact() twice to player-name-only input must produce the same result as applying it once.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIdempotenceCoordsOnly(): void
|
||||||
|
{
|
||||||
|
$input = implode("\n", [
|
||||||
|
'[16-04-26 12:00:00.000] 76561198000000001 "Player1" added Base.Aerosolbomb at 1000,2000,0.',
|
||||||
|
'[16-04-26 12:00:01.000] [76561198000000001][ISEnterVehicle][Player1][1020,2020,-1][Van_LectroMax].',
|
||||||
|
'[16-04-26 17:14:35.128][INFO] Combat: "Player1" (1005,2005,0) hit "Player2" (1006,2005,0) weapon="Tire Iron (Worn)" damage=0.112317.',
|
||||||
|
'[16-04-26 16:17:49.731][LOG] Safety: "Player1" (1000,2000,0) restore true.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$redactor = (new ProjectZomboidRedactor())->redactSteamIds(false)->redactPlayerNames(false);
|
||||||
|
$redacted = $redactor->redact($input);
|
||||||
|
$redactedAgain = $redactor->redact($redacted);
|
||||||
|
|
||||||
|
$this->assertSame($redacted, $redactedAgain, 'Applying redact() twice to coords-only input must produce the same result as applying it once; the placeholder 0,0,0 must not be re-matched.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIdempotenceAllCategories(): void
|
||||||
|
{
|
||||||
|
// Full input: all three PII categories in multiple lexical contexts.
|
||||||
|
// After the first redact(), every placeholder is in place. The second
|
||||||
|
// redact() must make no further changes.
|
||||||
|
$input = implode("\n", [
|
||||||
|
'[16-04-26 12:00:00.000] 76561198111111111 "Player1" added Base.Aerosolbomb at 1000,2000,0.',
|
||||||
|
'[16-04-26 12:00:01.000] 76561198222222222 "Player2" teleported to 1050,2050,0.',
|
||||||
|
"[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='AdminUser', text='hello'}.",
|
||||||
|
'[16-04-26 17:14:35.128][INFO] Combat: "Player1" (1005,2005,0) hit "Player2" (1006,2005,0) weapon="Tire Iron (Worn)" damage=0.112317.',
|
||||||
|
'[16-04-26 16:17:49.731][LOG] Safety: "Player1" (1000,2000,0) restore true.',
|
||||||
|
'[16-04-26 12:00:02.000] [76561198333333333][ISEnterVehicle][Player2][1020,2020,0][Van_LectroMax].',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$redactor = new ProjectZomboidRedactor();
|
||||||
|
$redacted = $redactor->redact($input);
|
||||||
|
$redactedAgain = $redactor->redact($redacted);
|
||||||
|
|
||||||
|
$this->assertSame($redacted, $redactedAgain, 'Applying redact() twice to input with all PII categories must produce the same result as applying it once; no placeholder must re-match on the second pass.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IndifferentKetchup\Codex\Test\Tests\Util\Redactor;
|
||||||
|
|
||||||
|
use IndifferentKetchup\Codex\Log\File\PathLogFile;
|
||||||
|
use IndifferentKetchup\Codex\Log\File\StringLogFile;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidAdminLog;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidBurdJournalsLog;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidChatLog;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidClientActionLog;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidCmdLog;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidItemLog;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidMapLog;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidPerkLog;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidPvpLog;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidServerLog;
|
||||||
|
use IndifferentKetchup\Codex\Log\ProjectZomboid\ProjectZomboidUserLog;
|
||||||
|
use IndifferentKetchup\Codex\Util\ProjectZomboid\ProjectZomboidRedactor;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests: drive all 11 existing PZ fixtures through ProjectZomboidRedactor
|
||||||
|
* and verify that the output is well-formed.
|
||||||
|
*
|
||||||
|
* Three properties are checked across all fixtures:
|
||||||
|
*
|
||||||
|
* 1. Steam ID normalisation — no non-zero-placeholder Steam IDs survive.
|
||||||
|
* 2. Structural preservation — parsing the redacted content yields the same
|
||||||
|
* entry count as parsing the original.
|
||||||
|
* 3. Idempotence — applying redact() a second time produces no further changes.
|
||||||
|
*
|
||||||
|
* Known v1 limitations documented inline:
|
||||||
|
*
|
||||||
|
* - pvp.txt: victim names after `hit "..."` are NOT redacted (Task 3 limitation).
|
||||||
|
* Player2 can therefore still appear after `hit` in the redacted pvp output.
|
||||||
|
* - pvp.txt: victim coords after `hit "(x,y,z)"` are NOT redacted (Task 4
|
||||||
|
* limitation). COORDS_PARENTHESISED_REGEX anchors on the trailing PvP verb
|
||||||
|
* which is present only for the attacker bracket.
|
||||||
|
* - admin.txt: `teleported X to <x,y,z>` coords survive because COORDS_AT_CLAUSE_REGEX
|
||||||
|
* anchors on ` at `, not ` to `.
|
||||||
|
*/
|
||||||
|
class ProjectZomboidRedactorIntegrationTest extends TestCase
|
||||||
|
{
|
||||||
|
private static string $fixturesDir = __DIR__ . '/../../../src/Games/ProjectZomboid/fixtures';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data providers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yields [fixturePath] for every PZ fixture file.
|
||||||
|
*/
|
||||||
|
public static function fixturePathProvider(): array
|
||||||
|
{
|
||||||
|
$dir = self::$fixturesDir;
|
||||||
|
return [
|
||||||
|
'admin' => [$dir . '/admin-minimal.txt'],
|
||||||
|
'burd-journals' => [$dir . '/burd-journals-minimal.txt'],
|
||||||
|
'chat' => [$dir . '/chat-minimal.txt'],
|
||||||
|
'client-action' => [$dir . '/client-action-minimal.txt'],
|
||||||
|
'cmd' => [$dir . '/cmd-minimal.txt'],
|
||||||
|
'debug-server' => [$dir . '/debug-server-minimal.txt'],
|
||||||
|
'item' => [$dir . '/item-minimal.txt'],
|
||||||
|
'map' => [$dir . '/map-minimal.txt'],
|
||||||
|
'perk' => [$dir . '/perk-minimal.txt'],
|
||||||
|
'pvp' => [$dir . '/pvp-minimal.txt'],
|
||||||
|
'user' => [$dir . '/user-minimal.txt'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yields [fixturePath] for the subset of fixtures where every synthetic
|
||||||
|
* player name (Player1 / Player2 / AdminUser / PlayerSuspect) appears
|
||||||
|
* exclusively in a context the redactor recognises:
|
||||||
|
*
|
||||||
|
* - chat: ChatMessage{author='...'} envelope
|
||||||
|
* - cmd, item, map, user: 77-char-Steam-ID followed by "..." quoted name
|
||||||
|
*
|
||||||
|
* Fixtures intentionally excluded:
|
||||||
|
*
|
||||||
|
* - admin: names appear in free-text positions (no Steam-ID anchor,
|
||||||
|
* no quotes, no Combat:/Safety: prefix). Names survive in v1.
|
||||||
|
* - client-action,
|
||||||
|
* perk: names appear inside [...] brackets, not "..." quotes.
|
||||||
|
* PLAYER_AFTER_STEAMID_REGEX requires double-quotes.
|
||||||
|
* - pvp: attacker name redacts but victim name after `hit "..."`
|
||||||
|
* survives in v1 (Task 3 limitation).
|
||||||
|
* - burd-journals,
|
||||||
|
* debug-server: no synthetic player names present.
|
||||||
|
*/
|
||||||
|
public static function fixturesWhereAllNamesAreInCoveredContextsProvider(): array
|
||||||
|
{
|
||||||
|
$dir = self::$fixturesDir;
|
||||||
|
return [
|
||||||
|
'chat' => [$dir . '/chat-minimal.txt'],
|
||||||
|
'cmd' => [$dir . '/cmd-minimal.txt'],
|
||||||
|
'item' => [$dir . '/item-minimal.txt'],
|
||||||
|
'map' => [$dir . '/map-minimal.txt'],
|
||||||
|
'user' => [$dir . '/user-minimal.txt'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yields [fixturePath, logClass] for the fixtures whose log class parses
|
||||||
|
* them. All 11 fixtures are represented.
|
||||||
|
*/
|
||||||
|
public static function fixtureWithLogClassProvider(): array
|
||||||
|
{
|
||||||
|
$dir = self::$fixturesDir;
|
||||||
|
return [
|
||||||
|
'admin' => [$dir . '/admin-minimal.txt', ProjectZomboidAdminLog::class],
|
||||||
|
'burd-journals' => [$dir . '/burd-journals-minimal.txt', ProjectZomboidBurdJournalsLog::class],
|
||||||
|
'chat' => [$dir . '/chat-minimal.txt', ProjectZomboidChatLog::class],
|
||||||
|
'client-action' => [$dir . '/client-action-minimal.txt', ProjectZomboidClientActionLog::class],
|
||||||
|
'cmd' => [$dir . '/cmd-minimal.txt', ProjectZomboidCmdLog::class],
|
||||||
|
'debug-server' => [$dir . '/debug-server-minimal.txt', ProjectZomboidServerLog::class],
|
||||||
|
'item' => [$dir . '/item-minimal.txt', ProjectZomboidItemLog::class],
|
||||||
|
'map' => [$dir . '/map-minimal.txt', ProjectZomboidMapLog::class],
|
||||||
|
'perk' => [$dir . '/perk-minimal.txt', ProjectZomboidPerkLog::class],
|
||||||
|
'pvp' => [$dir . '/pvp-minimal.txt', ProjectZomboidPvpLog::class],
|
||||||
|
'user' => [$dir . '/user-minimal.txt', ProjectZomboidUserLog::class],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private function redact(string $content): string
|
||||||
|
{
|
||||||
|
return (new ProjectZomboidRedactor())->redact($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 1 — Steam ID normalisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After redaction every 17-digit Steam ID that is NOT the zero-placeholder
|
||||||
|
* must be gone. The zero-placeholder itself (76561198000000000) is the only
|
||||||
|
* Steam ID that may remain.
|
||||||
|
*/
|
||||||
|
#[DataProvider('fixturePathProvider')]
|
||||||
|
public function testFixtureContainsNoSteamIdsAfterRedaction(string $fixturePath): void
|
||||||
|
{
|
||||||
|
$content = (new PathLogFile($fixturePath))->getContent();
|
||||||
|
$redacted = $this->redact($content);
|
||||||
|
|
||||||
|
$matches = preg_match_all('/(?<![A-Za-z0-9])76561198(?!000000000)\d{9}(?![A-Za-z0-9])/u', $redacted);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
0,
|
||||||
|
$matches,
|
||||||
|
sprintf(
|
||||||
|
'After redaction, fixture "%s" must contain no non-zero-placeholder Steam IDs, but %d were found.',
|
||||||
|
basename($fixturePath),
|
||||||
|
$matches,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 2 — Structural preservation (re-parse after redaction)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The redacted content, fed back through the corresponding parser, must
|
||||||
|
* produce exactly the same number of log entries as the original content.
|
||||||
|
*
|
||||||
|
* This asserts that the redactor does not corrupt timestamps, delimiters,
|
||||||
|
* or structural tokens that the parser relies on.
|
||||||
|
*
|
||||||
|
* @param string $fixturePath Path to the fixture file.
|
||||||
|
* @param class-string<\IndifferentKetchup\Codex\Log\Log> $logClass
|
||||||
|
* Fully-qualified name of the Log subclass that corresponds to this fixture.
|
||||||
|
*/
|
||||||
|
#[DataProvider('fixtureWithLogClassProvider')]
|
||||||
|
public function testFixtureRedactedOutputParsesToSameEntryCount(string $fixturePath, string $logClass): void
|
||||||
|
{
|
||||||
|
$content = (new PathLogFile($fixturePath))->getContent();
|
||||||
|
|
||||||
|
/** @var \IndifferentKetchup\Codex\Log\Log $originalLog */
|
||||||
|
$originalLog = (new $logClass())->setLogFile(new PathLogFile($fixturePath));
|
||||||
|
$originalLog->parse();
|
||||||
|
$originalCount = count($originalLog->getEntries());
|
||||||
|
|
||||||
|
$redacted = $this->redact($content);
|
||||||
|
|
||||||
|
/** @var \IndifferentKetchup\Codex\Log\Log $redactedLog */
|
||||||
|
$redactedLog = (new $logClass())->setLogFile(new StringLogFile($redacted));
|
||||||
|
$redactedLog->parse();
|
||||||
|
$redactedCount = count($redactedLog->getEntries());
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$originalCount,
|
||||||
|
$redactedCount,
|
||||||
|
sprintf(
|
||||||
|
'Parsing the redacted "%s" fixture with %s must yield the same entry count (%d) as parsing the original, but got %d.',
|
||||||
|
basename($fixturePath),
|
||||||
|
$logClass,
|
||||||
|
$originalCount,
|
||||||
|
$redactedCount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 3 — Idempotence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applying redact() a second time must produce no further changes:
|
||||||
|
* redact(redact(content)) === redact(content).
|
||||||
|
*
|
||||||
|
* This guards against poorly-anchored regexes that would re-match the
|
||||||
|
* redaction placeholders themselves on a second pass.
|
||||||
|
*/
|
||||||
|
#[DataProvider('fixturePathProvider')]
|
||||||
|
public function testFixtureIsIdempotent(string $fixturePath): void
|
||||||
|
{
|
||||||
|
$content = (new PathLogFile($fixturePath))->getContent();
|
||||||
|
|
||||||
|
$redactor = new ProjectZomboidRedactor();
|
||||||
|
$once = $redactor->redact($content);
|
||||||
|
$twice = $redactor->redact($once);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$once,
|
||||||
|
$twice,
|
||||||
|
sprintf(
|
||||||
|
'redact(redact(content)) must equal redact(content) for fixture "%s"; a second pass must be a no-op.',
|
||||||
|
basename($fixturePath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 4 — Player-name collapse in fully-covered fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For fixtures where every synthetic player name appears exclusively in a
|
||||||
|
* context the redactor recognises, no synthetic name should remain after
|
||||||
|
* redaction.
|
||||||
|
*
|
||||||
|
* This addresses observation #3 from the final code review (the integration
|
||||||
|
* tests previously asserted Steam-ID elimination + structural preservation
|
||||||
|
* + idempotence, but did not directly verify name collapse). The unit tests
|
||||||
|
* in ProjectZomboidRedactorPlayerNameTest cover this property exhaustively
|
||||||
|
* per-context; this integration test re-verifies it end-to-end against the
|
||||||
|
* fixtures that ride into iblogs.
|
||||||
|
*/
|
||||||
|
#[DataProvider('fixturesWhereAllNamesAreInCoveredContextsProvider')]
|
||||||
|
public function testFixturePlayerNamesCollapseInCoveredContexts(string $fixturePath): void
|
||||||
|
{
|
||||||
|
$content = (new PathLogFile($fixturePath))->getContent();
|
||||||
|
$redacted = $this->redact($content);
|
||||||
|
|
||||||
|
foreach (['Player1', 'Player2', 'AdminUser', 'PlayerSuspect'] as $name) {
|
||||||
|
$this->assertStringNotContainsString(
|
||||||
|
$name,
|
||||||
|
$redacted,
|
||||||
|
sprintf(
|
||||||
|
'Fixture "%s": synthetic name %s survived redaction. Every name in this fixture should appear only in a covered lexical context.',
|
||||||
|
basename($fixturePath),
|
||||||
|
$name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IndifferentKetchup\Codex\Test\Tests\Util\Redactor;
|
||||||
|
|
||||||
|
use IndifferentKetchup\Codex\Util\ProjectZomboid\ProjectZomboidRedactor;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ProjectZomboidRedactorPlayerNameTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testRedactsPlayerNameAfterRedactedSteamId(): void
|
||||||
|
{
|
||||||
|
// The Steam ID pass has already run; the literal placeholder 76561198000000000
|
||||||
|
// precedes the quoted name. The player-name pass must redact the name.
|
||||||
|
$input = '76561198000000000 "AdminUser" admin.broadcastMessage @ 1020,2020,0.';
|
||||||
|
$expected = '76561198000000000 "<player>" admin.broadcastMessage @ 1020,2020,0.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'Player name following the redacted Steam ID placeholder must be replaced.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedactsChatMessageAuthor(): void
|
||||||
|
{
|
||||||
|
// The author field inside ChatMessage{...} must be replaced; the text
|
||||||
|
// payload ('hello') is not in scope for player-name redaction and must
|
||||||
|
// survive unchanged.
|
||||||
|
$input = "[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='Player1', text='hello'}.";
|
||||||
|
$expected = "[16-04-26 17:05:03.280][info] Got message:ChatMessage{chat=Local, author='<player>', text='hello'}.";
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'ChatMessage author must be replaced while the text payload remains unchanged.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedactsCombatNameInPvpLog(): void
|
||||||
|
{
|
||||||
|
// Only the FIRST quoted name (after "Combat: ") is redacted in v1.
|
||||||
|
// The second name (after "hit") is NOT yet redacted — deferred to v2.
|
||||||
|
// The weapon name ("Tire Iron (Worn)") must also survive unchanged.
|
||||||
|
$input = '[16-04-26 17:14:35.128][INFO] Combat: "Player1" (1005,2005,0) hit "Player2" (1006,2005,0) weapon="Tire Iron (Worn)" damage=0.112317.';
|
||||||
|
// Attacker coords (before "hit") are also replaced by the coordinates pass.
|
||||||
|
// Victim coords (before "weapon=") lack the trailing keyword and are NOT replaced — deferred to v2.
|
||||||
|
$expected = '[16-04-26 17:14:35.128][INFO] Combat: "<player>" (0,0,0) hit "Player2" (1006,2005,0) weapon="Tire Iron (Worn)" damage=0.112317.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
// Player1 (after "Combat: ") is replaced; attacker coords (before "hit") are also replaced.
|
||||||
|
// Player2 (after "hit") and victim coords (before "weapon=") are NOT replaced in v1 — deferred.
|
||||||
|
$this->assertSame($expected, $output, 'First Combat: player name and attacker coords must be replaced; second name, victim coords, and weapon must survive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRedactsSafetyNameInPvpLog(): void
|
||||||
|
{
|
||||||
|
$input = '[16-04-26 16:17:49.731][LOG] Safety: "Player1" (1000,2000,0) restore true.';
|
||||||
|
// Coords (before ") restore") are also replaced by the coordinates pass.
|
||||||
|
$expected = '[16-04-26 16:17:49.731][LOG] Safety: "<player>" (0,0,0) restore true.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'Player name and coords following the Safety: token must both be replaced.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBareQuotedStringWithoutAnchorIsNotTouched(): void
|
||||||
|
{
|
||||||
|
// "foo" is not preceded by a redacted Steam ID, not inside ChatMessage{...},
|
||||||
|
// and not after Combat:/Safety: — it must pass through unchanged.
|
||||||
|
$input = 'option changed to "foo" successfully.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($input, $output, 'A quoted string with no matching anchor must not be redacted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToggleOffLeavesNamesIntact(): void
|
||||||
|
{
|
||||||
|
$input = '76561198000000000 "Player1" ISLogSystem.writeLog @ 1000,2000,0.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redactPlayerNames(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($input, $output, 'With the player-name toggle disabled the original input must be returned unchanged.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace IndifferentKetchup\Codex\Test\Tests\Util\Redactor;
|
||||||
|
|
||||||
|
use IndifferentKetchup\Codex\Util\ProjectZomboid\ProjectZomboidRedactor;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ProjectZomboidRedactorSteamIdTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testCollapsesDistinctSteamIdsToZeroPlaceholder(): void
|
||||||
|
{
|
||||||
|
$input = 'Players: 76561198111111111, 76561198222222222, 76561198333333333 connected.';
|
||||||
|
$expected = 'Players: 76561198000000000, 76561198000000000, 76561198000000000 connected.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($expected, $output, 'All three distinct Steam IDs should be replaced with the zero placeholder.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonSteamIdLongDigitsAreNotTouched(): void
|
||||||
|
{
|
||||||
|
// 13-digit Unix-millisecond timestamp (PZ log t: shape) and a 17-digit number
|
||||||
|
// that does not begin with 76561198 — neither should be altered.
|
||||||
|
$input = 't:1776297642406 score=12345678901234567';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($input, $output, 'Non-SteamID digit sequences must not be modified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmbeddedSteamIdInsideLongerAlphanumericTokenIsNotTouched(): void
|
||||||
|
{
|
||||||
|
// The SteamID64 pattern is embedded inside a longer alphanumeric token;
|
||||||
|
// the negative lookaround boundaries should prevent a match.
|
||||||
|
$input = 'token=abc76561198000000001def other=data';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($input, $output, 'A Steam ID embedded inside an alphanumeric token must not be redacted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToggleOffLeavesSteamIdsIntact(): void
|
||||||
|
{
|
||||||
|
$input = 'Connected: 76561198111111111 and 76561198222222222.';
|
||||||
|
|
||||||
|
$output = (new ProjectZomboidRedactor())
|
||||||
|
->redactSteamIds(false)
|
||||||
|
->redact($input);
|
||||||
|
|
||||||
|
$this->assertSame($input, $output, 'With the Steam ID toggle disabled the original input must be returned unchanged.');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user