Skip to content
This repository has been archived by the owner on Oct 8, 2020. It is now read-only.

Commit

Permalink
Implement Xgit.Core.Ref. (#211)
Browse files Browse the repository at this point in the history
* Implement Xgit.Core.Ref.

* Fix doc-formatting typo.

* Coverage for valid?/1 but not a Ref.
  • Loading branch information
scouten committed Nov 1, 2019
1 parent 155b7b4 commit f6377a7
Show file tree
Hide file tree
Showing 2 changed files with 287 additions and 0 deletions.
108 changes: 108 additions & 0 deletions lib/xgit/core/ref.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
defmodule Xgit.Core.Ref do
@moduledoc ~S"""
A reference is a struct that describes a mutable pointer to a commit or similar object.
A reference is a key-value pair where the key is a name in a specific format
(see [`git check-ref-format`](https://git-scm.com/docs/git-check-ref-format))
and the value (`:target`) is either a SHA-1 hash or a reference to another reference key
(i.e. `ref: (name-of-valid-ref)`).
This structure contains the key-value pair and functions to validate both values.
"""

import Xgit.Util.ForceCoverage

alias Xgit.Core.ObjectId

@typedoc ~S"""
This struct describes a single reference stored or about to be stored in a git
repository.
## Struct Members
* `:name`: the name of the reference (typically `refs/heads/master` or similar)
* `:target`: the object ID currently marked by this reference or a symbolic link
(`ref: refs/heads/master` or similar) to another reference
"""
@type t :: %__MODULE__{
name: String.t(),
target: ObjectId.t() | String.t()
}

@enforce_keys [:name, :target]
defstruct [:name, :target]

@doc ~S"""
Return `true` if the struct describes a valid reference.
## Options
`allow_one_level?`: Set to `true` to disregard the rule requiring at least one `/`
in name. (Similar to `--allow-onelevel` option.)
`refspec?`: Set to `true` to allow a single `*` in the pattern. (Similar to
`--refspec-pattern` option.)
"""
@spec valid?(ref :: any, allow_one_level?: boolean) :: boolean
def valid?(ref, opts \\ [])

def valid?(%__MODULE__{name: name, target: target}, opts)
when is_binary(name) and is_binary(target)
when is_list(opts) do
valid_name?(
name,
Keyword.get(opts, :allow_one_level?, false),
Keyword.get(opts, :refspec?, false)
) && valid_target?(target)
end

def valid?(_, _opts), do: cover(false)

defp valid_name?("@", _, _), do: cover(false)

defp valid_name?(name, true, false) do
all_components_valid?(name) && not Regex.match?(~r/[\x00-\x20\\\?\[:^\x7E\x7F]/, name) &&
not String.ends_with?(name, ".") && not String.contains?(name, "@{")
end

defp valid_name?(name, false, false) do
String.contains?(name, "/") && valid_name?(name, true, false) &&
not String.contains?(name, "*")
end

defp valid_name?(name, false, true) do
String.contains?(name, "/") && valid_name?(name, true, false) && at_most_one_asterisk?(name)
end

defp all_components_valid?(name) do
name
|> String.split("/")
|> Enum.all?(&name_component_valid?/1)
end

defp name_component_valid?(component), do: not name_component_invalid?(component)

defp name_component_invalid?(""), do: cover(true)

defp name_component_invalid?(component) do
String.starts_with?(component, ".") ||
String.ends_with?(component, ".lock") ||
String.contains?(component, "..")
end

@asterisk_re ~r/\*/

defp at_most_one_asterisk?(name) do
@asterisk_re
|> Regex.scan(name)
|> Enum.count()
|> Kernel.<=(1)
end

defp valid_target?(target), do: ObjectId.valid?(target) || valid_ref_target?(target)

defp valid_ref_target?("ref: " <> target_name),
do: valid_name?(target_name, false, false) && String.starts_with?(target_name, "refs/")

defp valid_ref_target?(_), do: cover(false)
end
179 changes: 179 additions & 0 deletions test/xgit/core/ref_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
defmodule Xgit.Core.RefTest do
use ExUnit.Case, async: true

alias Xgit.Core.Ref

defp assert_valid_name(name, opts \\ []) do
assert {_, 0} = System.cmd("git", check_ref_format_args(name, opts))
assert Ref.valid?(%Ref{name: name, target: "155b7b4b7a6b798725df04a6cfcfb1aa042f0834"}, opts)

if Enum.empty?(opts) && String.starts_with?(name, "refs/"),
do: assert(Ref.valid?(%Ref{name: "refs/heads/master", target: "ref: #{name}"}, opts))
end

defp refute_valid_name(name, opts \\ []) do
assert {_, 1} = System.cmd("git", check_ref_format_args(name, opts))
refute Ref.valid?(%Ref{name: name, target: "155b7b4b7a6b798725df04a6cfcfb1aa042f0834"}, opts)

if Enum.empty?(opts) && String.starts_with?(name, "refs/"),
do: refute(Ref.valid?(%Ref{name: "refs/heads/master", target: "ref: #{name}"}, opts))
end

defp check_ref_format_args(name, opts) do
["check-ref-format"]
|> maybe_add_allow_one_level(Keyword.get(opts, :allow_one_level?, false))
|> maybe_add_refspec(Keyword.get(opts, :refspec?, false))
|> Kernel.++([name])
end

defp maybe_add_allow_one_level(args, false), do: args
defp maybe_add_allow_one_level(args, true), do: args ++ ["--allow-onelevel"]

defp maybe_add_refspec(args, false), do: args
defp maybe_add_refspec(args, true), do: args ++ ["--refspec-pattern"]

describe "valid?/1 name" do
# From documentation for git check-ref-format
# (https://git-scm.com/docs/git-check-ref-format):

# "Git imposes the following rules on how references are named:"

test "can include slash / for hierarchical (directory) grouping" do
assert_valid_name("refs/heads")
assert_valid_name("refs/heads/master")
assert_valid_name("refs/heads/group/subgroup")
end

test "no slash-separated component can begin with a dot . or end with the sequence .lock." do
refute_valid_name(".refs/heads")
refute_valid_name("refs/.heads/master")
refute_valid_name("refs/heads/.master")
refute_valid_name("refs.lock/heads")
refute_valid_name("refs/heads.lock")
assert_valid_name("refs.lockx/heads")
assert_valid_name("refs/heads_lock")
end

test "must contain at least one /" do
# This enforces the presence of a category like heads/, tags/ etc.
# but the actual names are not restricted.
assert_valid_name("refs/heads")
assert_valid_name("refs/heads/master")
refute_valid_name("refs")
refute_valid_name("")
end

test "if the --allow-onelevel option is used, this rule is waived" do
assert_valid_name("refs/heads", allow_one_level?: true)
assert_valid_name("refs/heads/master", allow_one_level?: true)
assert_valid_name("refs", allow_one_level?: true)
refute_valid_name("", allow_one_level?: true)
# Empty name is still disallowed.
end

test "cannot have two consecutive dots .. anywhere" do
assert_valid_name("refs/he.ds")
refute_valid_name("refs/he..ds")
refute_valid_name("refs/../blah")
refute_valid_name("refs../heads")
end

test "cannot have ASCII control characters" do
# (i.e. bytes whose values are lower than \040, or \177 DEL), space, tilde ~, caret ^,
# or colon : anywhere.
refute_valid_name("refs/he\u001fads")
refute_valid_name("refs/he\u007fads")
refute_valid_name("refs/he ads")
refute_valid_name("refs/~heads")
refute_valid_name("refs/^heads")
refute_valid_name("refs/he:ads")
end

test "cannot have question-mark ?, asterisk *, or open bracket [ anywhere" do
refute_valid_name("refs/he?ads")
refute_valid_name("refs/heads?")
refute_valid_name("refs/heads/*")
refute_valid_name("refs/heads*/foo")
refute_valid_name("refs/heads/[foo")
refute_valid_name("refs/hea[ds/foo")
end

test "allows a single asterisk * with --refspec-pattern" do
# See the --refspec-pattern option below for an exception to this rule.
refute_valid_name("refs/heads/*")
assert_valid_name("refs/heads/*", refspec?: true)
refute_valid_name("refs/heads*/foo")
assert_valid_name("refs/heads*/foo", refspec?: true)
end

test "cannot begin or end with a slash / or contain multiple consecutive slashes" do
refute_valid_name("/refs/heads/foo")
refute_valid_name("refs/heads/master/")
refute_valid_name("refs//heads/master")
end

test "cannot end with a dot ." do
assert_valid_name("refs./heads/master")
refute_valid_name("refs/heads/master.")
end

test "cannot contain a sequence @{" do
assert_valid_name("refs/heads/@master")
assert_valid_name("refs/heads/{master")
refute_valid_name("refs/heads/@{master")
end

test "cannot be the single character @" do
assert_valid_name("refs/@/master")
refute_valid_name("@")
refute_valid_name("@", allow_one_level?: true)
end

test "cannot contain a \\" do
refute_valid_name("refs\\heads/master")
end
end

describe "valid?/1 target" do
defp assert_valid_target(target) do
assert Ref.valid?(%Ref{name: "refs/heads/master", target: target})
end

defp refute_valid_target(target) do
refute Ref.valid?(%Ref{name: "refs/heads/master", target: target})
end

test "object ID" do
assert_valid_target("1234567890abcdef12341234567890abcdef1234")
refute_valid_target("1234567890abcdef1231234567890abcdef1234")
refute_valid_target("1234567890abcdef123451234567890abcdef1234")
refute_valid_target("1234567890abCdef12341234567890abcdef1234")
refute_valid_target("1234567890abXdef12341234567890abcdef1234")

refute_valid_target(nil)
end

test "ref" do
assert_valid_target("ref: refs/heads/master")
refute_valid_target("ref:")
refute_valid_target("rex: refs/heads/master")
refute_valid_target("ref: refs")
end

test "ref must point inside of refs/ hierarchy" do
refute_valid_target("ref: refsxyz/heads/master")
refute_valid_target("ref: rex/heads/master")
end
end

test "valid?/1 not a Ref" do
refute Ref.valid?("refs/heads/master")
refute Ref.valid?(42)
refute Ref.valid?(nil)

refute Ref.valid?(%{
name: "refs/heads/master",
target: "155b7b4b7a6b798725df04a6cfcfb1aa042f0834"
})
end
end

0 comments on commit f6377a7

Please sign in to comment.