Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 59 additions & 1 deletion src/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
}
66 changes: 66 additions & 0 deletions tests/GroupTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Loading