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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `AbstractAlphabet` base class: one symbol per UTF-8 character (multibyte characters and emoji included);
validates at least two distinct characters; override `isReserved()` to exclude outputs.
- `AbstractUrlSafeAlphabet` base class: restricts characters to the RFC 3986 unreserved set and reserves any
output ending in `.` (including `.` and `..`); extend it for custom URL-safe subsets or extra `isReserved()` rules.
output ending in `.` (including `.` and `..`) or `-`, since email linkifiers drop either from the end of a URL;
extend it for custom URL-safe subsets or extra `isReserved()` rules.
- URL-safe presets `UrlSafe` (default, full 66-char unreserved set), `Alphanumeric` (62), `LowercaseAlphanumeric` (36)
and `Base64Url` (64), each a fixed-set class extending `AbstractUrlSafeAlphabet`.
- `Generic` alphabet: any UTF-8 character set passed to the constructor, no URL-safety check, reserves nothing.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Supporting pieces, each in its own namespace under `src/`:
the escalation ceiling for auto-increment.
- **`Alphabet/`**: `AlphabetInterface` (`size()`, `characterAt()`, `isReserved()`) with
`AbstractAlphabet` doing UTF-8 splitting and validation. `AbstractUrlSafeAlphabet` adds the
RFC 3986 unreserved-set check and reserves any output ending in `.`. The four presets
RFC 3986 unreserved-set check and reserves any output ending in `.` or `-`. The four presets
(`UrlSafe`, `Alphanumeric`, `LowercaseAlphanumeric`, `Base64Url`) are final classes with a
`CHARACTERS` constant; `Generic` takes any string and reserves nothing. Custom reservation rules
extend `AbstractUrlSafeAlphabet`, not a preset.
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,14 +245,15 @@ All alphabets live in the `Chrismou\StringMint\Alphabet` namespace.
| `new LowercaseAlphanumeric()` | a-z 0-9 | 36 | Safe for case-insensitive DB collations |
| `new Base64Url()` | A-Z a-z 0-9 `-_` | 64 | Base64url character set |
| `new Generic($chars)` | any UTF-8 characters | varies | No URL-safety check, reserves nothing |
| extend `AbstractUrlSafeAlphabet` | your subset of `-._~` A-Z a-z 0-9 | varies | Keeps the URL-safety check and trailing-dot guard |
| extend `AbstractUrlSafeAlphabet` | your subset of `-._~` A-Z a-z 0-9 | varies | Keeps the URL-safety check and trailing-character guard |
| extend `AbstractAlphabet` | any UTF-8 characters | varies | See "Custom alphabets" below |

**Trailing-dot guard:** Every URL-safe alphabet (`UrlSafe`, `Alphanumeric`, `LowercaseAlphanumeric`, `Base64Url`,
and anything extending `AbstractUrlSafeAlphabet`) reserves any output ending in `.`. That covers `.` and `..`,
which every URL parser normalises away so they would silently collide with other paths, and strings like `abc.`,
where chat clients and email linkifiers drop the trailing dot. `Generic` and
custom alphabets built on `AbstractAlphabet` reserve nothing unless they override `isReserved()`.
**Trailing-character guard:** Every URL-safe alphabet (`UrlSafe`, `Alphanumeric`, `LowercaseAlphanumeric`,
`Base64Url`, and anything extending `AbstractUrlSafeAlphabet`) reserves any output ending in `.` or `-`. The dot
covers `.` and `..`, which every URL parser normalises away so they would silently collide with other paths, and
strings like `abc.`, where chat clients and email linkifiers drop the trailing dot. The dash covers strings like
`abc-`, where some email clients stop the link before the dash and break the URL. `Generic` and custom alphabets
built on `AbstractAlphabet` reserve nothing unless they override `isReserved()`.

**Case-insensitive collations:** MySQL `utf8mb4_general_ci` and SQL Server defaults treat `abc`
and `ABC` as equal. A unique index will reject strings the generator considers distinct. Use
Expand Down Expand Up @@ -305,7 +306,7 @@ Each UTF-8 character is one symbol, so accented letters, Greek or emoji work as-
string from that alphabet is 8 bytes. Size your column accordingly.

To keep the URL-safe character set and only exclude a few outputs, extend `AbstractUrlSafeAlphabet` instead -
you keep the RFC 3986 validation and the trailing-dot guard:
you keep the RFC 3986 validation and the trailing-character guard:

```php
use Chrismou\StringMint\Alphabet\AbstractUrlSafeAlphabet;
Expand Down
17 changes: 10 additions & 7 deletions src/Alphabet/AbstractUrlSafeAlphabet.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
* Base class for alphabets restricted to the RFC 3986 unreserved set (A-Z a-z 0-9 - . _ ~).
*
* Validates every character against the unreserved set on top of AbstractAlphabet's checks, and reserves any
* output ending in ".": that covers "." and "..", which every URL parser normalises away, and strings like
* "abc.", where the trailing dot is dropped by chat clients and email linkifiers. Nothing else is reserved.
* output ending in "." or "-": "." covers "." and "..", which every URL parser normalises away, and strings
* like "abc.", where the trailing dot is dropped by chat clients and email linkifiers. "-" covers strings like
* "abc-", where some email clients stop the link before the dash and break the URL. Nothing else is reserved.
*
* Extend this class to build a URL-safe alphabet from your own subset of the unreserved characters, or to
* exclude more outputs by overriding isReserved() (call parent::isReserved() to keep the trailing-dot guard).
* exclude more outputs by overriding isReserved() (call parent::isReserved() to keep the trailing-character
* guard).
*/
abstract class AbstractUrlSafeAlphabet extends AbstractAlphabet
{
Expand All @@ -31,14 +33,15 @@ public function __construct(string $characters)
}

/**
* Returns true for any candidate ending in ".", which covers the "." and ".." path-segment hazards and
* strings that auto-linkers would truncate.
* Returns true for any candidate ending in "." or "-": "." covers the "." and ".." path-segment hazards,
* and both characters are dropped from the end of a URL by chat clients and email linkifiers.
*
* Subclasses that extend this method should call parent::isReserved() to preserve the trailing-dot guard.
* Subclasses that extend this method should call parent::isReserved() to preserve the trailing-character
* guard.
*/
public function isReserved(string $candidate): bool
{
return str_ends_with($candidate, '.');
return str_ends_with($candidate, '.') || str_ends_with($candidate, '-');
}

/**
Expand Down
21 changes: 18 additions & 3 deletions tests/Unit/Alphabet/AbstractUrlSafeAlphabetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,27 @@ public function testIsReservedReturnsTrueForAnyTrailingDot(): void
$this->assertTrue($alphabet->isReserved('abc-1.'));
}

#[Test]
public function testIsReservedReturnsTrueForAnyTrailingDash(): void
{
$alphabet = new class (AbstractUrlSafeAlphabet::RFC3986_UNRESERVED) extends AbstractUrlSafeAlphabet {};
$this->assertTrue($alphabet->isReserved('-'));
$this->assertTrue($alphabet->isReserved('--'));
$this->assertTrue($alphabet->isReserved('a-'));
$this->assertTrue($alphabet->isReserved('abc.1-'));
}

#[Test]
public function testIsReservedReturnsFalseForOtherStrings(): void
{
$alphabet = new class (AbstractUrlSafeAlphabet::RFC3986_UNRESERVED) extends AbstractUrlSafeAlphabet {};
$this->assertFalse($alphabet->isReserved('a'));
$this->assertFalse($alphabet->isReserved('.a'));
$this->assertFalse($alphabet->isReserved('a.b'));
$this->assertFalse($alphabet->isReserved('-a'));
$this->assertFalse($alphabet->isReserved('a-b'));
$this->assertFalse($alphabet->isReserved('a_'));
$this->assertFalse($alphabet->isReserved('a~'));
$this->assertFalse($alphabet->isReserved(''));
}

Expand All @@ -102,14 +116,15 @@ public function testSubclassCanOverrideIsReservedWhileKeepingUnreservedValidatio
$extended = new class (AbstractUrlSafeAlphabet::RFC3986_UNRESERVED) extends AbstractUrlSafeAlphabet {
public function isReserved(string $candidate): bool
{
return parent::isReserved($candidate) || str_starts_with($candidate, '-');
return parent::isReserved($candidate) || str_starts_with($candidate, '_');
}
};

$this->assertSame(66, $extended->size());
$this->assertTrue($extended->isReserved('.'));
$this->assertTrue($extended->isReserved('-a'));
$this->assertFalse($extended->isReserved('a-'));
$this->assertTrue($extended->isReserved('a-'));
$this->assertTrue($extended->isReserved('_a'));
$this->assertFalse($extended->isReserved('a_'));
}

#[Test]
Expand Down
3 changes: 2 additions & 1 deletion tests/Unit/Alphabet/AlphanumericTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,11 @@ public function testCharactersAreASubsetOfTheUnreservedSet(): void
}

#[Test]
public function testInheritsTheTrailingDotGuard(): void
public function testInheritsTheTrailingCharacterGuard(): void
{
$alphabet = new Alphanumeric();
$this->assertTrue($alphabet->isReserved('a.'));
$this->assertTrue($alphabet->isReserved('a-'));
$this->assertFalse($alphabet->isReserved('a'));
}
}
3 changes: 2 additions & 1 deletion tests/Unit/Alphabet/Base64UrlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,11 @@ public function testCharactersAreASubsetOfTheUnreservedSet(): void
}

#[Test]
public function testInheritsTheTrailingDotGuard(): void
public function testInheritsTheTrailingCharacterGuard(): void
{
$alphabet = new Base64Url();
$this->assertTrue($alphabet->isReserved('a.'));
$this->assertTrue($alphabet->isReserved('a-'));
$this->assertFalse($alphabet->isReserved('a'));
}
}
3 changes: 2 additions & 1 deletion tests/Unit/Alphabet/LowercaseAlphanumericTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ public function testCharactersAreASubsetOfTheUnreservedSet(): void
}

#[Test]
public function testInheritsTheTrailingDotGuard(): void
public function testInheritsTheTrailingCharacterGuard(): void
{
$alphabet = new LowercaseAlphanumeric();
$this->assertTrue($alphabet->isReserved('a.'));
$this->assertTrue($alphabet->isReserved('a-'));
$this->assertFalse($alphabet->isReserved('a'));
}
}
3 changes: 2 additions & 1 deletion tests/Unit/Alphabet/UrlSafeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ public function testCharactersAreASubsetOfTheUnreservedSet(): void
}

#[Test]
public function testInheritsTheTrailingDotGuard(): void
public function testInheritsTheTrailingCharacterGuard(): void
{
$alphabet = new UrlSafe();
$this->assertTrue($alphabet->isReserved('a.'));
$this->assertTrue($alphabet->isReserved('a-'));
$this->assertFalse($alphabet->isReserved('a'));
}
}
Loading