Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Parser / Composer #2

Merged
merged 1 commit into from
Apr 1, 2023
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
14 changes: 11 additions & 3 deletions .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
Expand Down Expand Up @@ -82,7 +81,6 @@
{Credo.Check.Refactor.FilterCount, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},

#
## Warnings
Expand Down Expand Up @@ -123,7 +121,6 @@
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, [include_defp: true]},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.IoPuts, []},
Expand All @@ -134,6 +131,16 @@
{Credo.Check.Warning.UnsafeToAtom, []}
],
disabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},

#
## Refactoring Opportunities
#
{Credo.Check.Refactor.RedundantWithClauseResult, []},

#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
Expand All @@ -144,6 +151,7 @@
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Readability.OnePipePerLine, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/part_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ jobs:

steps:
- uses: actions/checkout@v3
with:
submodules: 'true'
- uses: erlef/setup-beam@v1
id: setupBEAM
with:
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "test/spec"]
path = test/spec
url = git@github.com:package-url/purl-spec.git
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ See [the documentation][docs].

## Usage

TODO
```elixir
iex> Purl.new("pkg:hex/purl")
{:ok, %Purl{type: "hex", name: "purl"}}

iex> Purl.to_string(%Purl{type: "hex", name: "purl"})
"pkg:hex/purl"
```

## Installation

Expand Down
237 changes: 230 additions & 7 deletions lib/purl.ex
Original file line number Diff line number Diff line change
@@ -1,19 +1,242 @@
defmodule Purl do
@moduledoc """
Documentation for `Purl`.
Elixir Implementation of the purl (package url) specification.

## Specification

https://github.com/package-url/purl-spec

**Format**: `pkg:type/namespace/name@version?qualifiers#subpath`

> #### License {: .neutral}
>
> A lot of the documentation was taken directly from the specification. It is
> licensed under the MIT License:
> ```
> Copyright (c) the purl authors
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of
> this software and associated documentation files (the "Software"), to deal in
> the Software without restriction, including without limitation the rights to
> use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
> the Software, and to permit persons to whom the Software is furnished to do so,
> subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all
> copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
> FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
> COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
> IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
> CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
> ```
"""

alias Purl.Composer
alias Purl.Parser
alias Purl.SpecialCase

# credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct
@type parse_error ::
%URI.Error{}
| Purl.Error.InvalidField.t()
| Purl.Error.DuplicateQualifier.t()
| Purl.Error.InvalidScheme.t()

@typedoc """
the package "type" or package "protocol" such as `maven`, `npm`, `nuget`,
`gem`, `pypi`, etc.

Known types: https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst

## Validation

* The package type is composed only of ASCII letters and numbers, '.', '+' and '-' (period, plus, and dash)
* The type cannot start with a number
* The type cannot contains spaces
* The type must NOT be percent-encoded
* The type is case insensitive. The canonical form is lowercase
"""
@type type :: String.t()

@typedoc """
Segment of the namespace

## Validation

* must not contain a '/'
* must not be empty
* A URL host or Authority must NOT be used as a namespace. Use instead a
`repository_url` qualifier. Note however that for some types, the namespace
may look like a host.
"""
@type namespace_segment :: String.t()

@typedoc """
some name prefix such as a Maven groupid, a Docker image owner, a GitHub user
or organization

The values are type-specific.
"""
@type namespace :: [namespace_segment()]

@typedoc """
the name of the package
"""
@type name :: String.t()

@typedoc """
the version of the package

A version is a plain and opaque string. Some package types use versioning
conventions such as semver for NPMs or nevra conventions for RPMS. A type may
define a procedure to compare and sort versions, but there is no reliable and
uniform way to do such comparison consistently.
"""
@type version :: Version.t() | String.t()

@typedoc """
qualifier key

## Validation

* The key must be composed only of ASCII letters and numbers, '.', '-' and '_' (period, dash and underscore)
* A key cannot start with a number
* A key must NOT be percent-encoded
* A key is case insensitive. The canonical form is lowercase
* A key cannot contains spaces
"""
@type qualifier_key :: String.t()

@typedoc """
qualifier value

## Validation
* value cannot be an empty string: a key=value pair with an empty value is the
same as no key/value at all for this key
"""
@type qualifier_value :: String.t()

@typedoc """
extra qualifying data for a package such as an OS, architecture, a distro,
etc.

The values are type-specific.

## Validation
* key must be unique within the keys of the qualifiers string
"""
@type qualifiers :: %{optional(qualifier_key()) => qualifier_value()}

@typedoc """
subpath segment

## Validation
* must not contain a '/'
* must not be any of '..' or '.'
* must not be empty
"""
@type subpath_segment :: String.t()

@typedoc """
extra subpath within a package, relative to the package root
"""
@type subpath :: [subpath_segment()]

@typedoc """
Package URL struct
"""
@type t :: %__MODULE__{
type: type(),
namespace: namespace(),
name: name(),
version: version() | nil,
qualifiers: qualifiers(),
subpath: subpath()
}

@enforce_keys [:type, :name]
defstruct [:type, :name, namespace: [], version: nil, qualifiers: %{}, subpath: []]

@doc """
Formats purl as string

## Examples

iex> Purl.to_string(%Purl{type: "hex", name: "purl"})
"pkg:hex/purl"

"""
@spec to_string(purl :: t()) :: String.t()
def to_string(%Purl{} = purl), do: purl |> to_uri() |> URI.to_string()

# @doc """
# Converts a purl to a `URI`

# ## Examples

# iex> Purl.to_uri(%Purl{type: "hex", name: "purl"})
# %URI{scheme: "pkg", path: "hex/purl"}

# """
@spec to_uri(purl :: t()) :: URI.t()
defdelegate to_uri(purl), to: Composer, as: :compose_uri

@doc """
Hello world.
Creates a new purl struct from a `Purl`, `URI` or string.

## Examples

iex> Purl.hello()
:world
iex> Purl.new("pkg:hex/purl")
{:ok, %Purl{type: "hex", name: "purl"}}

"""
@spec hello :: :world
def hello do
:world
@spec new(purl :: String.t() | URI.t() | Purl.t()) ::
{:ok, Purl.t()} | {:error, parse_error() | Purl.Error.SpecialCaseFailed.t()}
def new(purl) do
with {:ok, purl} <- Parser.parse(purl),
{:ok, purl} <- SpecialCase.apply(purl) do
{:ok, purl}
end
end

@doc """
Similar to `new/1` but raises `URI.Error`, `Purl.Error.InvalidField` or
`Purl.Error.InvalidURI` if an invalid input is given.

## Examples

iex> Purl.new!("pkg:hex/purl")
%Purl{type: "hex", name: "purl"}

iex> Purl.new!(">pkg:hex/purl")
** (URI.Error) cannot parse due to reason invalid_uri: ">"

iex> Purl.new!("pkg:hex*/purl")
** (Purl.Error.InvalidField) invalid field type, \"hex*\" given

"""
@spec new!(purl :: String.t() | URI.t() | Purl.t()) :: Purl.t()
def new!(purl) do
case new(purl) do
{:ok, purl} -> purl
{:error, reason} -> raise reason
end
end

defimpl String.Chars do
@impl String.Chars
def to_string(%Purl{} = purl), do: Purl.to_string(purl)
end

defimpl Inspect do
import Inspect.Algebra

@impl Inspect
def inspect(%Purl{} = purl, opts) do
concat(["Purl.parse!(", to_doc(Purl.to_string(purl), opts), ")"])
end
end
end
56 changes: 56 additions & 0 deletions lib/purl/composer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
defmodule Purl.Composer do
@moduledoc false

@spec compose_uri(purl :: Purl.t()) :: URI.t()
def compose_uri(purl)

def compose_uri(%Purl{version: %Version{} = version} = purl),
do: compose_uri(%Purl{purl | version: Version.to_string(version)})

def compose_uri(%Purl{
type: type,
namespace: namespace,
name: name,
version: version,
qualifiers: qualifiers,
subpath: subpath
}) do
%URI{
scheme: "pkg",
path:
Enum.join(
[type | encode_namespace(namespace)] ++
[
case version do
nil -> name
version -> "#{name}@#{URI.encode(version)}"
end
],
"/"
),
query:
unless qualifiers == %{} do
encode_qualifiers(qualifiers)
end,
fragment:
unless subpath == [] do
Enum.join(subpath, "/")
end
}
end

@spec encode_namespace(namespace :: Purl.namespace()) :: [String.t()]
defp encode_namespace(namespace) do
Enum.map(namespace, fn namespace_segment ->
URI.encode(namespace_segment, &(&1 != ?@ and URI.char_unescaped?(&1)))
end)
end

@spec encode_qualifiers(qualifiers :: Purl.qualifiers()) :: String.t()
defp encode_qualifiers(qualifiers) do
Enum.map_join(qualifiers, "&", fn {key, value} ->
URI.encode(key, &URI.char_unreserved?/1) <>
"=" <> URI.encode(value, &(&1 == ?/ or URI.char_unescaped?(&1)))
end)
end
end
Loading