From c114e7494819b30d40cabdcb46c95d8f48163c7b Mon Sep 17 00:00:00 2001 From: beamologist Date: Wed, 14 Oct 2026 15:46:31 +0200 Subject: [PATCH 1/2] Group no longer prints its grant when it is dumped or encoded The org's grant is a bearer credential a distributor accepts, and Group held it in a public property with nothing to redact it: print_r, var_dump and a json-encoded log context all printed it, the last including a Group nested in the context a logger encodes. macula-php#11. __debugInfo() and JsonSerializable now name the grant as present rather than printing it, which is what macula-py's Group does with repr=False and what macula-dotnet's UcanPresentation does with a private PrintMembers. options() is untouched: what reaches publishGroup and subscribeGroup still carries the grant, and the redaction is on the way to a log, not on the way to the wire. Three ways of reading an object are outside what a PHP class can redact, so the class doc names them instead of leaving them to be assumed away: an (array) cast and var_export() ignore __debugInfo and expose every property including the private ones, and serialize() writes what a caller asked to keep. The test pins all three so that a PHP which ever gains a hook for them fails the test rather than silently leaving the doc wrong. The new test was run against the pre-fix Group.php first and failed there on print_r showing the grant. Offline suite: 49 tests, 231 assertions, green. --- CHANGELOG.md | 18 +++++++++++++++++ src/Group.php | 44 +++++++++++++++++++++++++++++++++++++++++- tests/GroupTest.php | 47 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6118ba..b55ec1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ 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. + Three ways of reading an object are outside what a PHP class can redact, + and the class doc names them rather than leaving them to be assumed away: + an `(array)` cast and `var_export()` expose every property including the + private ones, and `serialize()` writes what a caller asked to keep. + ## [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..23663a9 100644 --- a/src/Group.php +++ b/src/Group.php @@ -18,8 +18,19 @@ * 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 a debugger or a log shows names + * it as present rather than printing it, as macula-py's Group and + * macula-dotnet's UcanPresentation do. `print_r`, `var_dump` and + * `json_encode`, the last including a Group nested in the context a logger + * encodes, are what this class can redact. Three ways of reading an object are + * outside any class's control and therefore show it: an `(array)` cast and + * `var_export()` ignore __debugInfo and expose every property including the + * private ones, and `serialize()` writes them for a caller who asked for + * persistence, which is a request to keep the group working rather than to log + * it. Do not reach for those three when what is wanted is something to print. */ -final class Group +final class Group implements \JsonSerializable { /** * @param string $prefix the group's prefix, at least two `/`-segments @@ -57,4 +68,35 @@ public function options(): array } return $o; } + + /** + * What `print_r`, `var_dump` and every debugger 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. + * + * Note what this does and does not reach: an `(array)` cast, `var_export()` + * and `serialize()` are outside it, as the class doc says. + * + * @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..9101807 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -411,4 +411,51 @@ 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); + $dumped = (string) ob_get_clean(); + + foreach ([ + 'print_r' => print_r($group, true), + 'var_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)); + + // What PHP does not let a class close: an (array) cast and var_export() + // ignore __debugInfo, and serialize() writes what a caller asked to keep. + // Asserted so the class doc's boundary is checked, not believed. + self::assertStringContainsString($grant, print_r((array) $group, true), 'an (array) cast exposes it'); + self::assertStringContainsString($grant, var_export($group, true), 'var_export() exposes it'); + self::assertStringContainsString($grant, serialize($group), 'serialize() keeps it'); + } } From 542e59dfa827d543433a2b6a6a686b6448466b4f Mon Sep 17 00:00:00 2001 From: beamologist Date: Wed, 14 Oct 2026 15:51:49 +0200 Subject: [PATCH 2/2] Fable round 1: the boundary statement was not true, and the wire was unasserted Two required changes from Saturnus, both text and test. The redaction itself it found correct, and it probed PHP 8.5 to confirm which paths honour __debugInfo. 1. The class doc said three ways of reading an object were outside the class's control. It was wrong twice over. More paths leak than the three named: get_object_vars, a foreach, an IDE step debugger (DBGp reads the property table), Symfony's VarDumper whose dump() and dd() start from an (array) cast and add __debugInfo's keys alongside the real ones, and PHPUnit's own exporter, which reports a failed assertEquals/assertSame on a Group through (array) and so prints the grant into the CI log. And serialize() is not a PHP limit at all: __serialize() exists, so keeping the grant there is this class's choice, and a persisted group working when it comes back is worth more than hiding it from the caller who asked to store it. The doc, the __debugInfo doc, the CHANGELOG and the test's comments now say what is redacted, what is not, and which of the latter is a choice. "every debugger" was false and is gone. The three positive assertions are phrased as "not redacted by this class", which is what they show. 2. Nothing asserted the grant still reaches the wire. A later refactor routing options() through __debugInfo would have stayed green here until a live run. options()['ucan'] is now asserted to be the grant, and a group with none to carry no key. Also covered: nested var_dump and debug_zval_dump in the redacted set, and get_object_vars in the not-redacted one. Offline suite: 49 tests, 234 assertions, green. --- CHANGELOG.md | 12 ++++++++---- src/Group.php | 48 ++++++++++++++++++++++++++++++--------------- tests/GroupTest.php | 33 ++++++++++++++++++++++++------- 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b55ec1d..d0d6c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,10 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. - Three ways of reading an object are outside what a PHP class can redact, - and the class doc names them rather than leaving them to be assumed away: - an `(array)` cast and `var_export()` expose every property including the - private ones, and `serialize()` writes what a caller asked to keep. + 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 diff --git a/src/Group.php b/src/Group.php index 23663a9..d467d50 100644 --- a/src/Group.php +++ b/src/Group.php @@ -19,16 +19,28 @@ * 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 a debugger or a log shows names - * it as present rather than printing it, as macula-py's Group and - * macula-dotnet's UcanPresentation do. `print_r`, `var_dump` and - * `json_encode`, the last including a Group nested in the context a logger - * encodes, are what this class can redact. Three ways of reading an object are - * outside any class's control and therefore show it: an `(array)` cast and - * `var_export()` ignore __debugInfo and expose every property including the - * private ones, and `serialize()` writes them for a caller who asked for - * persistence, which is a request to keep the group working rather than to log - * it. Do not reach for those three when what is wanted is something to print. + * 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 implements \JsonSerializable { @@ -70,13 +82,17 @@ public function options(): array } /** - * What `print_r`, `var_dump` and every debugger 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. + * 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. * - * Note what this does and does not reach: an `(array)` cast, `var_export()` - * and `serialize()` are outside it, as the class doc says. + * 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} */ diff --git a/tests/GroupTest.php b/tests/GroupTest.php index 9101807..41867df 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -429,11 +429,13 @@ public function testAGroupNeverPrintsItsGrant(): void 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' => $dumped, + '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) { @@ -451,11 +453,28 @@ public function testAGroupNeverPrintsItsGrant(): void 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)); - // What PHP does not let a class close: an (array) cast and var_export() - // ignore __debugInfo, and serialize() writes what a caller asked to keep. - // Asserted so the class doc's boundary is checked, not believed. - self::assertStringContainsString($grant, print_r((array) $group, true), 'an (array) cast exposes it'); - self::assertStringContainsString($grant, var_export($group, true), 'var_export() exposes it'); - self::assertStringContainsString($grant, serialize($group), 'serialize() keeps it'); + // 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'); } }