Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ jobs:
- name: Checkout
# see https://github.com/actions/checkout
uses: actions/checkout@v5
with:
submodules: recursive
- name: Setup PHP
# see https://github.com/shivammathur/setup-php
uses: shivammathur/setup-php@v2
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "spec"]
path = spec
url = https://github.com/package-url/purl-spec/
1 change: 1 addition & 0 deletions spec
Submodule spec added at a66765
51 changes: 48 additions & 3 deletions src/BuildParseTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ private function getNormalizerForNamespace(?string $type): \Closure
if (null !== $type) {
$type = strtolower($type);
}
if (\in_array($type, ['bitbucket', 'deb', 'github', 'golang', 'hex', 'rpm'], true)) {
if (\in_array($type, ['bitbucket', 'deb', 'github', 'golang', 'hex', 'rpm', 'composer'], true)) {
return static function (string $data): string {
return strtolower($data);
};
Expand All @@ -73,15 +73,58 @@ private function getNormalizerForNamespace(?string $type): \Closure
};
}

/**
* Normalize MLflow package name based on qualifiers.
*
* MLflow purl names are case-sensitive for Azure ML (keep as-is)
* and case-insensitive for Databricks (lowercase).
*
* @param mixed $qualifiers Can be string, array, or null
*/
public function normalize_mlflow_name(string $name, $qualifiers): ?string
{
if (\is_array($qualifiers)) {
$repoUrl = $qualifiers['repository_url'] ?? null;

if (null !== $repoUrl) {
$repoUrlLower = strtolower($repoUrl);
if (str_contains($repoUrlLower, 'azureml')) {
return $name;
}
if (str_contains($repoUrlLower, 'databricks')) {
return strtolower($name);
}
}
} elseif (\is_string($qualifiers)) {
$qualifiersLower = strtolower($qualifiers);
if (str_contains($qualifiersLower, 'azureml')) {
return $name;
}
if (str_contains($qualifiersLower, 'databricks')) {
return strtolower($name);
}
}

return $name;
}

/**
* @psalm-param non-empty-string $name
*
* @return non-empty-string
*/
private function normalizeNameForType(string $name, ?string $type): string
private function normalizeNameForType(string $name, ?string $type, $qualifiers): string
{
if (null !== $type) {
$type = strtolower($type);

if (!preg_match('/^[a-z0-9._-]+$/i', $type)) {
throw new \InvalidArgumentException(\sprintf('Type must be composed only of ASCII letters, numbers, period, dash, or underscore: "%s"', $type));
}

if (isset($type[0]) && ctype_digit($type[0])) {
throw new \InvalidArgumentException(\sprintf('Type cannot start with a number: "%s"', $type));
}
}
if ('pypi' === $type) {
/**
Expand All @@ -90,9 +133,11 @@ private function normalizeNameForType(string $name, ?string $type): string
* @psalm-var non-empty-string $name
*/
$name = str_replace('_', '-', $name);
} elseif ('mlflow' === $type) {
$name = $this->normalize_mlflow_name($name, $qualifiers);
}

if (\in_array($type, ['bitbucket', 'deb', 'github', 'golang', 'hex', 'npm', 'pypi'], true)) {
if (\in_array($type, ['bitbucket', 'deb', 'github', 'golang', 'hex', 'npm', 'pypi', 'composer'], true)) {
$name = strtolower($name);
}

Expand Down
19 changes: 12 additions & 7 deletions src/PackageUrl.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ public function getQualifiers(): ?array
}

/**
* @psalm-param TQualifiers $qualifiers
* @psalm-param TQualifiers $qualfifiers
*
* @throws \DomainException if checksums are part of the qualifiers. Use setChecksums() to set these.
*
Expand Down Expand Up @@ -286,12 +286,17 @@ public function setSubpath(?string $subpath): self

/**
* @throws \DomainException if a value was invalid
*
* @see settype()
* @see setName()
*/
final public function __construct(string $type, string $name)
final public function __construct(?string $type, ?string $name)
{
if (null === $type || '' === $type) {
throw new \Exception('Type is required for a Package URL.');
}

if (null === $name || '' === $name) {
throw new \Exception('Name is required for a Package URL.');
}

$this->setType($type);
$this->setName($name);
}
Expand Down Expand Up @@ -363,7 +368,7 @@ public static function fromString(string $data, ?PackageUrlParser $parser = null
throw new \DomainException('Type must not be empty');
}

$name = $parser->normalizeName($name, $type);
$name = $parser->normalizeName($name, $type, $qualifiers);
if (null === $name) {
throw new \DomainException('Name must not be empty');
}
Expand All @@ -372,7 +377,7 @@ public static function fromString(string $data, ?PackageUrlParser $parser = null

return (new static($type, $name))
->setNamespace($parser->normalizeNamespace($namespace, $type))
->setVersion($parser->normalizeVersion($version))
->setVersion($parser->normalizeVersion($version, $type))
->setQualifiers($qualifiers)
->setChecksums($checksums)
->setSubpath($parser->normalizeSubpath($subpath));
Expand Down
24 changes: 17 additions & 7 deletions src/PackageUrlBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ public function build(

$type = $this->normalizeType($type);
$namespace = $this->normalizeNamespace($namespace, $type);
$name = $this->normalizeName($name, $type);
$version = $this->normalizeVersion($version);
$name = $this->normalizeName($name, $type, $qualifiers);
$version = $this->normalizeVersion($version, $type);
$qualifiers = $this->normalizeQualifiers($qualifiers);
$subpath = $this->normalizeSubpath($subpath);

Expand Down Expand Up @@ -105,6 +105,10 @@ public function normalizeType(string $data): string
*/
public function normalizeNamespace(?string $data, string $type): ?string
{
if ('swift' == $type && null === $data) {
throw new \InvalidArgumentException('Invalid Swift PURL: missing namespace.');
}

if (null === $data) {
return null;
}
Expand All @@ -128,22 +132,26 @@ public function normalizeNamespace(?string $data, string $type): ?string
*
* @throws \DomainException if name is empty
*/
public function normalizeName(string $data, string $type): string
public function normalizeName(string $data, string $type, ?array $qualifiers): string
{
$data = trim($data, '/');
if ('' === $data) {
throw new \DomainException('name must not be empty');
}
$data = $this->normalizeNameForType($data, $type);
$data = $this->normalizeNameForType($data, $type, $qualifiers);

return $this->encode($data);
}

/**
* @psalm-return non-empty-string|null
*/
public function normalizeVersion(?string $data): ?string
public function normalizeVersion(?string $data, ?string $type): ?string
{
if ('swift' == $type && null === $data) {
throw new \InvalidArgumentException('Invalid Swift PURL: missing version.');
}

if (null === $data) {
return null;
}
Expand Down Expand Up @@ -175,7 +183,8 @@ public function normalizeQualifiers(?array $data): ?string

/** @var mixed $value */
foreach ($data as $key => $value) {
$key = (string) $key;
$key = strtolower((string) $key);

if ('' === $key) {
continue;
}
Expand Down Expand Up @@ -217,7 +226,8 @@ private function normalizeChecksum($data): ?string
$checksums = [];
/** @var mixed $checksum */
foreach ($data as $checksum) {
$checksum = (string) $checksum;
$checksum = strtolower((string) $checksum);

if ('' === $checksum) {
continue;
}
Expand Down
20 changes: 17 additions & 3 deletions src/PackageUrlParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ public function normalizeType(?string $data): ?string
*/
public function normalizeNamespace(?string $data, ?string $type): ?string
{
if ('swift' == $type && null === $data) {
throw new \InvalidArgumentException('Invalid Swift PURL: missing namespace.');
}

if (null === $data) {
return null;
}
Expand All @@ -179,7 +183,7 @@ public function normalizeNamespace(?string $data, ?string $type): ?string
/**
* @return TName|null
*/
public function normalizeName(?string $data, ?string $type): ?string
public function normalizeName(?string $data, ?string $type, $qualifiers): ?string
{
if (null === $data) {
return null;
Expand All @@ -189,20 +193,28 @@ public function normalizeName(?string $data, ?string $type): ?string
return null;
}

return $this->normalizeNameForType($name, $type);
return $this->normalizeNameForType($name, $type, $qualifiers);
}

/**
* @return TVersion
*/
public function normalizeVersion(?string $data): ?string
public function normalizeVersion(?string $data, ?string $type): ?string
{
if ('swift' == $type && null === $data) {
throw new \InvalidArgumentException('Invalid Swift PURL: missing version.');
}

if (null === $data) {
return null;
}

$version = rawurldecode($data);

if (\is_string($type) && \in_array(strtolower($type), ['huggingface', 'oci'], true)) {
$version = strtolower($version);
}

return '' === $version
? null
: $version;
Expand Down Expand Up @@ -242,6 +254,8 @@ public function normalizeQualifiers(?string $data): array
: explode(',', $qualifiers[PackageUrl::QUALIFIER_CHECKSUM]);
unset($qualifiers[PackageUrl::QUALIFIER_CHECKSUM]);

ksort($qualifiers);

return empty($qualifiers)
? [null, $checksums]
: [$qualifiers, $checksums];
Expand Down
18 changes: 3 additions & 15 deletions tests/PackageUrlBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,30 +75,28 @@ public function testNormalizeNamespace(?string $input, ?string $expectedOutput,

/**
* @dataProvider \PackageUrl\Tests\_data\MiscProvider::normalizeNameSpecials
* @dataProvider dpStringsToEncoded
*
* @psalm-param non-empty-string|null $type
*/
public function testNormalizeName(?string $input, ?string $expectedOutput, string $type = ''): void
{
$normalized = $this->sut->normalizeName($input, $type);
$normalized = $this->sut->normalizeName($input, $type, []);
self::assertSame($expectedOutput, $normalized);
}

public function testNormalizeNameSlash(): void
{
$this->expectException(\DomainException::class);
$this->expectExceptionMessageMatches('/name .*empty/i');
$this->sut->normalizeName('///', '');
$this->sut->normalizeName('///', '', []);
}

/**
* @dataProvider dpStringsToEncoded
* @dataProvider \PackageUrl\Tests\_data\MiscProvider::stringsEmptyAndNull
*/
public function testNormalizeVersion(?string $input, ?string $expectedOutput): void
{
$normalized = $this->sut->normalizeVersion($input);
$normalized = $this->sut->normalizeVersion($input, 'test');
self::assertSame($expectedOutput, $normalized);
}

Expand Down Expand Up @@ -195,16 +193,6 @@ public static function dpNormalizeNamespace(): \Generator
yield 'complex Namespace' => ['/yet/another//Name space/', 'yet/another/Name%20space'];
}

/**
* @psalm-return Generator<non-empty-string, array{string, array<string, string>}>
*/
public static function dpStringsToEncoded(): \Generator
{
yield 'some:string' => ['some:String', 'some:String'];
yield 'some/string' => ['some/String', 'some/String'];
yield 'encoded string' => ['some "encoded" string', 'some%20%22encoded%22%20string'];
}

/**
* @psalm-return Generator<non-empty-string, array{null|array<string, null|string>, null|string}>
*/
Expand Down
27 changes: 2 additions & 25 deletions tests/PackageUrlParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,29 +83,6 @@ public function testNormalizeNamespace(?string $input, ?string $expectedOutput,
self::assertSame($expectedOutput, $normalized);
}

/**
* @dataProvider \PackageUrl\Tests\_data\MiscProvider::normalizeNameSpecials
* @dataProvider dpStringsToDecoded
* @dataProvider \PackageUrl\Tests\_data\MiscProvider::stringsEmptyAndNull
*
* @psalm-param non-empty-string|null $type
*/
public function testNormalizeName(?string $input, ?string $expectedOutput, string $type = ''): void
{
$normalized = $this->sut->normalizeName($input, $type);
self::assertSame($expectedOutput, $normalized);
}

/**
* @dataProvider dpStringsToDecoded
* @dataProvider \PackageUrl\Tests\_data\MiscProvider::stringsEmptyAndNull
*/
public function testNormalizeVersion(?string $input, ?string $expectedOutput): void
{
$normalized = $this->sut->normalizeVersion($input);
self::assertSame($expectedOutput, $normalized);
}

/**
* @dataProvider dpNormalizeQualifiers
*/
Expand Down Expand Up @@ -147,8 +124,8 @@ public function testParseAndNormalize(array $data): void
$normalized = [
'type' => $this->sut->normalizeType($parsed['type']),
'namespace' => $this->sut->normalizeNamespace($parsed['namespace'], $parsed['type']),
'name' => $this->sut->normalizeName($parsed['name'], $parsed['type']),
'version' => $this->sut->normalizeVersion($parsed['version']),
'name' => $this->sut->normalizeName($parsed['name'], $parsed['type'], []),
'version' => $this->sut->normalizeVersion($parsed['version'], $parsed['type']),
'qualifiers' => $normalizedQualifiers,
'subpath' => $this->sut->normalizeSubpath($parsed['subpath']),
];
Expand Down
Loading