diff --git a/CHANGELOG.md b/CHANGELOG.md index c6118ba..d0d6c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ to v0.3.4 are described in their annotated git tag messages. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0-rc.4] - 2026-10-14 + +### Changed + +- **`Group` no longer prints its grant when it is dumped or encoded** + (macula-php#11). The org's grant is a bearer credential a distributor + accepts, and it was a public property with nothing to redact it, so + `print_r`, `var_dump` and a json-encoded log context printed it — the last + including a `Group` nested in the context a logger encodes. `__debugInfo()` + and `JsonSerializable` now name the grant as present rather than printing + it, as macula-py's `Group` and macula-dotnet's `UcanPresentation` already + do. `options()` is untouched: what goes to `publishGroup` and + `subscribeGroup` still carries the grant, which is the whole point of it. + What is *not* redacted is what a PHP class has no hook for — anything that + reads the property table: an `(array)` cast, `get_object_vars`, a `foreach`, + an IDE step debugger, Symfony's `dump()`, and PHPUnit's own exporter when a + comparison on a Group fails, which prints the grant into the CI log. The + class doc lists them so they are chosen around rather than assumed away. + `serialize()` also writes the grant, which is this class's choice and not a + limit, since `__serialize()` exists: a persisted group is meant to work when + it comes back. + ## [0.9.0-rc.3] - 2026-10-14 On macula-go v0.27.0-rc.3's libmacula (was v0.27.0-rc.1), which carries the diff --git a/src/Group.php b/src/Group.php index 09642eb..d467d50 100644 --- a/src/Group.php +++ b/src/Group.php @@ -18,8 +18,31 @@ * Pool::subscribeGroup, which join it first (pulling the current epoch from * the distributor over a sealed call the grant authorizes) and then seal the * publish under, or open the subscription's events under, that epoch. + * + * The `ucan` is a bearer credential, so what this class can redact, it redacts: + * `print_r`, `var_dump` (the object, and anything holding one), `debug_zval_dump`, + * an exception trace that prints arguments, and `json_encode`, the last + * including a Group nested in the context a logger encodes — Monolog's + * normalizer, for instance, takes jsonSerialize. macula-py's Group and + * macula-dotnet's UcanPresentation do the same for the same reason. + * + * What is NOT redacted, and cannot be, because a PHP class has no hook for + * anything that reads the property table: `get_object_vars`, a `foreach` over + * the object, an `(array)` cast, an IDE step debugger (DBGp reads the property + * table), and Symfony's VarDumper, whose `dump()` and `dd()` start from an + * `(array)` cast and add __debugInfo's keys alongside the real ones rather than + * instead of them. Two of those matter in practice: PHPUnit's own exporter + * reports a failed `assertEquals`/`assertSame` on a Group through `(array)`, so + * a failing test prints the grant into the CI log, and `dump()` prints it to + * the page or the console. Do not reach for those when what is wanted is + * something to print. + * + * `serialize()` also writes it, which is a choice rather than a limit, since + * __serialize() exists: a Group persisted to a queue or a cache is meant to + * still work when it comes back, and that is worth more than hiding the grant + * from the caller who asked to store it. */ -final class Group +final class Group implements \JsonSerializable { /** * @param string $prefix the group's prefix, at least two `/`-segments @@ -57,4 +80,39 @@ public function options(): array } return $o; } + + /** + * What `print_r`, `var_dump`, `debug_zval_dump`, an exception trace and + * every debugger that honours it show: the group, with its grant named as + * present rather than printed. A grant is a bearer credential a distributor + * accepts, so a debugging aid that showed it would hand out the group's keys + * to whoever reads the output. + * + * This reaches the paths that honour __debugInfo. It does not reach the ones + * that read the property table — an `(array)` cast, `get_object_vars`, a + * `foreach`, an IDE step debugger, Symfony's VarDumper, and PHPUnit's own + * exporter when a comparison fails. The class doc lists them; none is a + * reason to print a Group. + * + * @return array{prefix: string, ucan: string|null, distributor: string|null} + */ + public function __debugInfo(): array + { + return [ + 'prefix' => $this->prefix, + 'ucan' => $this->ucan === null ? null : '(set)', + 'distributor' => $this->distributor, + ]; + } + + /** + * What `json_encode` shows, including when a Group sits inside the context + * a logger encodes. The grant is redacted for the reason __debugInfo gives. + * + * @return array{prefix: string, ucan: string|null, distributor: string|null} + */ + public function jsonSerialize(): array + { + return $this->__debugInfo(); + } } diff --git a/tests/GroupTest.php b/tests/GroupTest.php index 447c22a..41867df 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -411,4 +411,70 @@ public function testTheLibraryRefusesAPrefixAndATopicThatAreNotAGroups(): void $pool->close(); } } + + /** + * macula-php#11: a group's grant is a bearer credential a distributor + * accepts, so what a class can redact is redacted, and the three paths PHP + * gives a class no hook for are pinned here rather than assumed — a PHP that + * ever gains one fails this test instead of silently leaving the class doc + * wrong. + */ + public function testAGroupNeverPrintsItsGrant(): void + { + // A grant for a node that is never called: this test formats a group and + // touches no station beyond the harness the class already starts. + $grant = self::$env->grant(str_repeat('ab', 32)); + $group = new Group($this->prefix(), ucan: $grant); + $without = new Group($this->prefix()); + + ob_start(); + var_dump($group); + var_dump(['group' => $group]); + debug_zval_dump($group); + $dumped = (string) ob_get_clean(); + + foreach ([ + 'print_r' => print_r($group, true), + 'var_dump, plain and nested, and debug_zval_dump' => $dumped, + 'json_encode' => json_encode($group, JSON_THROW_ON_ERROR), + 'a json_encode with the group nested' => json_encode(['group' => $group], JSON_THROW_ON_ERROR), + ] as $path => $printed) { + self::assertStringNotContainsString($grant, $printed, $path . ' shows the grant'); + // Not a part of it either, so a redaction that trimmed the text + // rather than leaving it out would fail here. + self::assertStringNotContainsString(substr($grant, 0, 64), $printed, $path . ' shows part of the grant'); + // Still the group, though: json_encode escapes the prefix's slashes + // unless it is asked not to, and that is the only difference. + self::assertStringContainsString($group->prefix, str_replace('\\/', '/', $printed), + $path . ' still names the group'); + } + + self::assertSame('(set)', $group->__debugInfo()['ucan'], 'a grant that is there reads as present'); + self::assertNull($without->__debugInfo()['ucan'], 'and a group with none says so rather than redacting'); + self::assertStringContainsString('"ucan":null', json_encode($without, JSON_THROW_ON_ERROR)); + + // The redaction is on the way to a log, not the way to the wire: the + // grant still reaches publishGroup and subscribeGroup through options(), + // which is the whole point of holding it. Without this, routing options() + // through __debugInfo would stay green here until a live run. + self::assertSame($grant, $group->options()['ucan'], 'the grant still reaches the wire'); + self::assertArrayNotHasKey('ucan', $without->options(), 'and a group with none carries none'); + + // What this class cannot redact: anything that reads the property table. + // These paths are not redacted by Group — they are not redacted at all — + // and they are asserted so the class doc's boundary is checked rather + // than believed. Two of them are what a failure prints: PHPUnit's own + // exporter reports a failed comparison through (array), and Symfony's + // dump() starts from one too. + self::assertStringContainsString($grant, print_r((array) $group, true), + 'an (array) cast is not redacted by this class'); + self::assertStringContainsString($grant, print_r(get_object_vars($group), true), + 'get_object_vars is not redacted by this class'); + self::assertStringContainsString($grant, var_export($group, true), + 'var_export is not redacted by this class'); + // Not a PHP limit but this class's choice: __serialize() could redact it + // and does not, so a group persisted to a queue or a cache still works + // when it comes back. + self::assertStringContainsString($grant, serialize($group), 'serialize() keeps it, by choice'); + } }