swhid 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +27 -0
- data/README.md +5 -0
- data/benchmark/benchmark.rb +54 -0
- data/exe/swhid +4 -0
- data/lib/swhid/from_filesystem.rb +79 -24
- data/lib/swhid/from_git.rb +30 -141
- data/lib/swhid/identifier.rb +76 -14
- data/lib/swhid/objects/content.rb +30 -3
- data/lib/swhid/objects/directory.rb +50 -14
- data/lib/swhid/objects/release.rb +4 -2
- data/lib/swhid/objects/revision.rb +4 -2
- data/lib/swhid/objects/snapshot.rb +16 -5
- data/lib/swhid/version.rb +1 -1
- data/lib/swhid.rb +4 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5851a0a381d49b2824a6371b3cc4e1148ba8d8e6791b82d849feb5ba1860801b
|
|
4
|
+
data.tar.gz: bbb8211dea616259f3e80a5ea567d70fe3e74f44b0634f900cec78706a6913a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7be028aff05051f058d466fa143ae540d0e360799996a9424a3193ae3d608e0362e5e853a2e2cdeca61fea635bd3fd304fea6d47e654fd64900d8c9c22e80f8d
|
|
7
|
+
data.tar.gz: 552340a1970dd51499e864d0c5ca2f6f8f9e9d4a0cb670723bcd7540f8bd5266afea57fabf5ba1d82b598df5f0653c79aa389144310a39a2b98d9cb04c46a41a
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-09-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Swhid.from_content_io` for hashing content from an IO without loading the entire value into memory
|
|
7
|
+
- Benchmarks for large content, directories, snapshots, and Git-backed identifiers
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Validate and canonicalize qualified SWHIDs, including nested identifiers and line and byte ranges
|
|
11
|
+
- Limit snapshots to `HEAD`, local branches, and tags
|
|
12
|
+
- Read Gitlink entries from the Git index when hashing filesystem directories
|
|
13
|
+
- Stream file content and reuse verified Git object IDs when computing identifiers
|
|
14
|
+
- Delay loading Rugged until a Git-backed API is called
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Reject duplicate or invalid directory entries and snapshot branch names
|
|
18
|
+
- Sort directory and snapshot names by raw bytes
|
|
19
|
+
- Accept `Swhid::Identifier` objects as directory targets without using Ruby object identity
|
|
20
|
+
|
|
21
|
+
## [0.4.2] - 2026-01-14
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- CLI now reads stdin and writes stdout in binary mode (fixes CRLF and binary file handling on Windows)
|
|
25
|
+
- Normalize path separators for Git index lookups on Windows
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- CLI integration tests for binary content and line ending preservation
|
|
29
|
+
|
|
3
30
|
## [0.4.1] - 2026-01-13
|
|
4
31
|
|
|
5
32
|
### Fixed
|
data/README.md
CHANGED
|
@@ -74,6 +74,11 @@ content = File.read("example.txt")
|
|
|
74
74
|
swhid = Swhid.from_content(content)
|
|
75
75
|
puts swhid.to_s # => "swh:1:cnt:..."
|
|
76
76
|
|
|
77
|
+
# Stream a large file
|
|
78
|
+
swhid = File.open("archive.tar", "rb") do |file|
|
|
79
|
+
Swhid.from_content_io(file, size: file.size)
|
|
80
|
+
end
|
|
81
|
+
|
|
77
82
|
# Empty file
|
|
78
83
|
swhid = Swhid.from_content("")
|
|
79
84
|
puts swhid.to_s # => "swh:1:cnt:e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
|
data/benchmark/benchmark.rb
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
require "bundler/setup"
|
|
5
5
|
require "swhid"
|
|
6
6
|
require "benchmark"
|
|
7
|
+
require "stringio"
|
|
8
|
+
require "tmpdir"
|
|
7
9
|
|
|
8
10
|
puts "SWHID Performance Benchmarks"
|
|
9
11
|
puts "=" * 60
|
|
@@ -27,6 +29,33 @@ Benchmark.bm(30) do |x|
|
|
|
27
29
|
x.report("Large content (1 MB):") do
|
|
28
30
|
100.times { Swhid.from_content(content_large) }
|
|
29
31
|
end
|
|
32
|
+
|
|
33
|
+
x.report("Large content IO (1 MB):") do
|
|
34
|
+
100.times do
|
|
35
|
+
Swhid.from_content_io(StringIO.new(content_large), size: content_large.bytesize)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
puts
|
|
41
|
+
puts "=" * 60
|
|
42
|
+
puts
|
|
43
|
+
puts "Filesystem directory hashing:"
|
|
44
|
+
|
|
45
|
+
Dir.mktmpdir("swhid-benchmark") do |directory_path|
|
|
46
|
+
require "rugged"
|
|
47
|
+
repository = Rugged::Repository.init_at(directory_path)
|
|
48
|
+
1_000.times do |i|
|
|
49
|
+
File.binwrite(File.join(directory_path, "file#{i}.txt"), "content #{i}\n")
|
|
50
|
+
end
|
|
51
|
+
repository.index.add_all
|
|
52
|
+
repository.index.write
|
|
53
|
+
|
|
54
|
+
Benchmark.bm(30) do |x|
|
|
55
|
+
x.report("Filesystem (1,000 files):") do
|
|
56
|
+
10.times { Swhid::FromFilesystem.from_directory_path(directory_path) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
30
59
|
end
|
|
31
60
|
|
|
32
61
|
puts
|
|
@@ -48,6 +77,10 @@ entries_large = 100.times.map do |i|
|
|
|
48
77
|
{ name: "file#{i}.txt", type: :file, target: "94a9ed024d3859793618152ea559a168bbcbb5e2" }
|
|
49
78
|
end
|
|
50
79
|
|
|
80
|
+
entries_xlarge = 1_000.times.map do |i|
|
|
81
|
+
{ name: "file#{i}.txt", type: :file, target: "94a9ed024d3859793618152ea559a168bbcbb5e2" }
|
|
82
|
+
end
|
|
83
|
+
|
|
51
84
|
Benchmark.bm(30) do |x|
|
|
52
85
|
x.report("Small directory (1 entry):") do
|
|
53
86
|
10_000.times { Swhid.from_directory(entries_small) }
|
|
@@ -60,6 +93,27 @@ Benchmark.bm(30) do |x|
|
|
|
60
93
|
x.report("Large directory (100 entries):") do
|
|
61
94
|
1_000.times { Swhid.from_directory(entries_large) }
|
|
62
95
|
end
|
|
96
|
+
|
|
97
|
+
x.report("XL directory (1,000 entries):") do
|
|
98
|
+
100.times { Swhid.from_directory(entries_xlarge) }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
puts
|
|
103
|
+
puts "=" * 60
|
|
104
|
+
puts
|
|
105
|
+
|
|
106
|
+
# Benchmark snapshot hashing
|
|
107
|
+
puts "Snapshot hashing:"
|
|
108
|
+
|
|
109
|
+
branches = 1_000.times.map do |i|
|
|
110
|
+
{ name: "refs/heads/branch#{i}", target_type: "revision", target: "94a9ed024d3859793618152ea559a168bbcbb5e2" }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
Benchmark.bm(30) do |x|
|
|
114
|
+
x.report("Snapshot (1,000 branches):") do
|
|
115
|
+
100.times { Swhid.from_snapshot(branches) }
|
|
116
|
+
end
|
|
63
117
|
end
|
|
64
118
|
|
|
65
119
|
puts
|
data/exe/swhid
CHANGED
|
@@ -6,6 +6,9 @@ require "swhid"
|
|
|
6
6
|
require "optparse"
|
|
7
7
|
require "json"
|
|
8
8
|
|
|
9
|
+
# Ensure consistent LF line endings on all platforms
|
|
10
|
+
$stdout.binmode
|
|
11
|
+
|
|
9
12
|
class SwhidCLI
|
|
10
13
|
def initialize(args)
|
|
11
14
|
@args = args
|
|
@@ -96,6 +99,7 @@ class SwhidCLI
|
|
|
96
99
|
end
|
|
97
100
|
|
|
98
101
|
def compute_content_swhid
|
|
102
|
+
$stdin.binmode
|
|
99
103
|
content = $stdin.read
|
|
100
104
|
|
|
101
105
|
swhid = Swhid.from_content(content)
|
|
@@ -10,7 +10,25 @@ module Swhid
|
|
|
10
10
|
raise ArgumentError, "Path is not a directory: #{path}" unless File.directory?(path)
|
|
11
11
|
|
|
12
12
|
git_repo ||= discover_git_repo(path)
|
|
13
|
-
|
|
13
|
+
repo_relative_path = relative_path_in_repo(path, git_repo) if git_repo
|
|
14
|
+
index_entries = load_git_index_entries(git_repo)
|
|
15
|
+
compute_directory_path(
|
|
16
|
+
path,
|
|
17
|
+
git_repo: git_repo,
|
|
18
|
+
permissions: permissions,
|
|
19
|
+
repo_relative_path: repo_relative_path,
|
|
20
|
+
index_entries: index_entries
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.compute_directory_path(path, git_repo:, permissions:, repo_relative_path:, index_entries:)
|
|
25
|
+
entries = build_entries(
|
|
26
|
+
path,
|
|
27
|
+
git_repo: git_repo,
|
|
28
|
+
permissions: permissions,
|
|
29
|
+
repo_relative_path: repo_relative_path,
|
|
30
|
+
index_entries: index_entries
|
|
31
|
+
)
|
|
14
32
|
Swhid.from_directory(entries)
|
|
15
33
|
end
|
|
16
34
|
|
|
@@ -21,8 +39,9 @@ module Swhid
|
|
|
21
39
|
nil
|
|
22
40
|
end
|
|
23
41
|
|
|
24
|
-
def self.build_entries(dir_path, git_repo: nil, permissions: nil)
|
|
42
|
+
def self.build_entries(dir_path, git_repo: nil, permissions: nil, repo_relative_path: nil, index_entries: nil)
|
|
25
43
|
entries = []
|
|
44
|
+
index_entries ||= load_git_index_entries(git_repo)
|
|
26
45
|
|
|
27
46
|
Dir.foreach(dir_path) do |name|
|
|
28
47
|
next if name == "." || name == ".."
|
|
@@ -30,21 +49,38 @@ module Swhid
|
|
|
30
49
|
|
|
31
50
|
full_path = File.join(dir_path, name)
|
|
32
51
|
stat = File.lstat(full_path)
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
relative_path = if repo_relative_path
|
|
53
|
+
repo_relative_path.empty? ? name : "#{repo_relative_path}/#{name}"
|
|
54
|
+
end
|
|
55
|
+
index_entry = relative_path && index_entries[relative_path]
|
|
56
|
+
|
|
57
|
+
entry = if index_entry && (index_entry[:mode] & 0o170000) == 0o160000
|
|
58
|
+
{ name: name, type: :rev, target: index_entry[:oid] }
|
|
59
|
+
elsif File.symlink?(full_path)
|
|
35
60
|
target_content = File.readlink(full_path)
|
|
36
61
|
target_hash = Swhid.from_content(target_content).object_hash
|
|
37
62
|
{ name: name, type: :symlink, target: target_hash }
|
|
38
63
|
elsif stat.directory?
|
|
39
|
-
target_swhid =
|
|
64
|
+
target_swhid = compute_directory_path(
|
|
65
|
+
full_path,
|
|
66
|
+
git_repo: git_repo,
|
|
67
|
+
permissions: permissions,
|
|
68
|
+
repo_relative_path: relative_path,
|
|
69
|
+
index_entries: index_entries
|
|
70
|
+
)
|
|
40
71
|
{ name: name, type: :dir, target: target_swhid.object_hash }
|
|
41
|
-
elsif file_executable?(
|
|
42
|
-
|
|
43
|
-
|
|
72
|
+
elsif file_executable?(
|
|
73
|
+
full_path,
|
|
74
|
+
stat,
|
|
75
|
+
git_repo,
|
|
76
|
+
permissions,
|
|
77
|
+
index_entry: index_entry,
|
|
78
|
+
index_checked: true
|
|
79
|
+
)
|
|
80
|
+
target_hash = content_swhid_from_file(full_path, stat.size).object_hash
|
|
44
81
|
{ name: name, type: :exec, target: target_hash }
|
|
45
82
|
else
|
|
46
|
-
|
|
47
|
-
target_hash = Swhid.from_content(content).object_hash
|
|
83
|
+
target_hash = content_swhid_from_file(full_path, stat.size).object_hash
|
|
48
84
|
{ name: name, type: :file, target: target_hash }
|
|
49
85
|
end
|
|
50
86
|
|
|
@@ -54,7 +90,13 @@ module Swhid
|
|
|
54
90
|
entries
|
|
55
91
|
end
|
|
56
92
|
|
|
57
|
-
def self.
|
|
93
|
+
def self.content_swhid_from_file(path, size)
|
|
94
|
+
File.open(path, "rb") do |file|
|
|
95
|
+
Swhid.from_content_io(file, size: size)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.file_executable?(full_path, stat, git_repo, permissions = nil, index_entry: nil, index_checked: false)
|
|
58
100
|
# Check explicit permissions map first (from tar extraction, etc.)
|
|
59
101
|
if permissions
|
|
60
102
|
real_path = File.realpath(full_path) rescue File.expand_path(full_path)
|
|
@@ -64,20 +106,29 @@ module Swhid
|
|
|
64
106
|
|
|
65
107
|
# Check Git index for tracked files
|
|
66
108
|
if git_repo
|
|
67
|
-
|
|
68
|
-
if
|
|
69
|
-
entry = git_repo.index[relative_path]
|
|
70
|
-
if entry
|
|
71
|
-
mode = entry[:mode]
|
|
72
|
-
return (mode & 0o111) != 0
|
|
73
|
-
end
|
|
74
|
-
end
|
|
109
|
+
index_entry ||= git_index_entry(full_path, git_repo) unless index_checked
|
|
110
|
+
return (index_entry[:mode] & 0o111) != 0 if index_entry
|
|
75
111
|
end
|
|
76
112
|
|
|
77
113
|
# Fall back to filesystem
|
|
78
114
|
stat.executable?
|
|
79
115
|
end
|
|
80
116
|
|
|
117
|
+
def self.git_index_entry(full_path, git_repo)
|
|
118
|
+
return nil unless git_repo
|
|
119
|
+
|
|
120
|
+
relative_path = relative_path_in_repo(full_path, git_repo)
|
|
121
|
+
relative_path && git_repo.index[relative_path]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.load_git_index_entries(git_repo)
|
|
125
|
+
return {} unless git_repo
|
|
126
|
+
|
|
127
|
+
git_repo.index.each_with_object({}) do |entry, entries|
|
|
128
|
+
entries[entry[:path]] = entry if entry[:stage].zero?
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
81
132
|
def self.relative_path_in_repo(full_path, git_repo)
|
|
82
133
|
repo_workdir = git_repo.workdir
|
|
83
134
|
return nil unless repo_workdir
|
|
@@ -86,13 +137,17 @@ module Swhid
|
|
|
86
137
|
full_path = File.realpath(full_path) rescue File.expand_path(full_path)
|
|
87
138
|
repo_workdir = File.realpath(repo_workdir) rescue File.expand_path(repo_workdir)
|
|
88
139
|
|
|
89
|
-
#
|
|
90
|
-
|
|
140
|
+
# Normalize path separators for consistent comparison (especially on Windows)
|
|
141
|
+
full_path = full_path.tr("\\", "/")
|
|
142
|
+
repo_workdir = repo_workdir.tr("\\", "/")
|
|
143
|
+
|
|
144
|
+
repo_workdir = repo_workdir.chomp("/")
|
|
145
|
+
return "" if full_path == repo_workdir
|
|
91
146
|
|
|
92
|
-
|
|
147
|
+
repo_prefix = "#{repo_workdir}/"
|
|
148
|
+
return nil unless full_path.start_with?(repo_prefix)
|
|
93
149
|
|
|
94
|
-
|
|
95
|
-
relative.tr("\\", "/")
|
|
150
|
+
full_path.delete_prefix(repo_prefix)
|
|
96
151
|
end
|
|
97
152
|
end
|
|
98
153
|
end
|
data/lib/swhid/from_git.rb
CHANGED
|
@@ -1,79 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "digest/sha1"
|
|
4
4
|
|
|
5
5
|
module Swhid
|
|
6
6
|
module FromGit
|
|
7
7
|
def self.from_revision(repo_path, ref = "HEAD")
|
|
8
|
-
repo =
|
|
8
|
+
repo = open_repository(repo_path)
|
|
9
9
|
commit = repo.rev_parse(ref)
|
|
10
10
|
|
|
11
11
|
raise ArgumentError, "Reference #{ref} is not a commit" unless commit.is_a?(Rugged::Commit)
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
parents: commit.parents.map(&:oid),
|
|
16
|
-
author: format_person(commit.author),
|
|
17
|
-
author_timestamp: commit.author[:time].to_i,
|
|
18
|
-
author_timezone: format_timezone(commit.author[:time]),
|
|
19
|
-
committer: format_person(commit.committer),
|
|
20
|
-
committer_timestamp: commit.committer[:time].to_i,
|
|
21
|
-
committer_timezone: format_timezone(commit.committer[:time]),
|
|
22
|
-
message: commit.message
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
# Extract extra headers if present (like gpgsig, svn headers, etc)
|
|
26
|
-
extra_headers = extract_extra_headers(repo, commit)
|
|
27
|
-
metadata[:extra_headers] = extra_headers unless extra_headers.empty?
|
|
28
|
-
|
|
29
|
-
Swhid.from_revision(metadata)
|
|
13
|
+
verify_git_object!(repo, commit.oid, :commit)
|
|
14
|
+
Identifier.new(object_type: "rev", object_hash: commit.oid)
|
|
30
15
|
end
|
|
31
16
|
|
|
32
17
|
def self.from_release(repo_path, tag_name)
|
|
33
|
-
repo =
|
|
18
|
+
repo = open_repository(repo_path)
|
|
34
19
|
tag_ref = repo.references["refs/tags/#{tag_name}"]
|
|
35
20
|
|
|
36
21
|
raise ArgumentError, "Tag #{tag_name} not found" unless tag_ref
|
|
37
22
|
|
|
38
|
-
# Get the tag object
|
|
39
23
|
tag_obj = repo.lookup(tag_ref.target_id)
|
|
40
24
|
|
|
41
|
-
# Check if it's an annotated tag
|
|
42
25
|
if tag_obj.is_a?(Rugged::Tag::Annotation)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
when Rugged::Tag::Annotation then "rel"
|
|
46
|
-
when Rugged::Tree then "dir"
|
|
47
|
-
when Rugged::Blob then "cnt"
|
|
48
|
-
else "rev"
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
metadata = {
|
|
52
|
-
name: tag_obj.name,
|
|
53
|
-
target: { hash: tag_obj.target.oid, type: target_type },
|
|
54
|
-
message: tag_obj.message
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if tag_obj.tagger
|
|
58
|
-
metadata[:author] = format_person(tag_obj.tagger)
|
|
59
|
-
metadata[:author_timestamp] = tag_obj.tagger[:time].to_i
|
|
60
|
-
metadata[:author_timezone] = format_timezone(tag_obj.tagger[:time])
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Extract extra headers if present (like gpgsig for signed tags)
|
|
64
|
-
extra_headers = extract_tag_extra_headers(repo, tag_obj)
|
|
65
|
-
metadata[:extra_headers] = extra_headers unless extra_headers.empty?
|
|
66
|
-
|
|
67
|
-
Swhid.from_release(metadata)
|
|
26
|
+
verify_git_object!(repo, tag_obj.oid, :tag)
|
|
27
|
+
Identifier.new(object_type: "rel", object_hash: tag_obj.oid)
|
|
68
28
|
else
|
|
69
|
-
# Lightweight tag - points directly to commit
|
|
70
29
|
raise ArgumentError, "Lightweight tags are not supported for release SWHIDs"
|
|
71
30
|
end
|
|
72
31
|
end
|
|
73
32
|
|
|
74
33
|
def self.from_snapshot(repo_path)
|
|
75
|
-
repo =
|
|
34
|
+
repo = open_repository(repo_path)
|
|
76
35
|
branches = []
|
|
36
|
+
target_cache = {}
|
|
77
37
|
|
|
78
38
|
# Check for HEAD first
|
|
79
39
|
head_path = File.join(repo.path, "HEAD")
|
|
@@ -90,12 +50,11 @@ module Swhid
|
|
|
90
50
|
end
|
|
91
51
|
end
|
|
92
52
|
|
|
93
|
-
# Get all references (branches and tags)
|
|
94
53
|
repo.references.each do |ref|
|
|
95
54
|
ref_name = ref.name
|
|
55
|
+
next unless ref_name.start_with?("refs/heads/", "refs/tags/")
|
|
96
56
|
|
|
97
57
|
if ref.type == :symbolic
|
|
98
|
-
# This is an alias (symbolic ref)
|
|
99
58
|
target_ref_name = ref.target
|
|
100
59
|
branches << {
|
|
101
60
|
name: ref_name,
|
|
@@ -103,22 +62,8 @@ module Swhid
|
|
|
103
62
|
target: target_ref_name
|
|
104
63
|
}
|
|
105
64
|
else
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
# Determine target type and OID
|
|
110
|
-
target_type, target_oid = case target_obj
|
|
111
|
-
when Rugged::Commit
|
|
112
|
-
["revision", target_obj.oid]
|
|
113
|
-
when Rugged::Tag::Annotation
|
|
114
|
-
["release", target_obj.oid]
|
|
115
|
-
when Rugged::Tree
|
|
116
|
-
["directory", target_obj.oid]
|
|
117
|
-
when Rugged::Blob
|
|
118
|
-
["content", target_obj.oid]
|
|
119
|
-
else
|
|
120
|
-
["revision", target_obj.oid]
|
|
121
|
-
end
|
|
65
|
+
target_oid = ref.target_id
|
|
66
|
+
target_type = target_cache[target_oid] ||= reference_target_type(repo, target_oid)
|
|
122
67
|
|
|
123
68
|
branches << {
|
|
124
69
|
name: ref_name,
|
|
@@ -133,87 +78,31 @@ module Swhid
|
|
|
133
78
|
|
|
134
79
|
private
|
|
135
80
|
|
|
136
|
-
def self.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def self.format_timezone(time)
|
|
141
|
-
offset = time.utc_offset
|
|
142
|
-
sign = offset >= 0 ? "+" : "-"
|
|
143
|
-
hours = offset.abs / 3600
|
|
144
|
-
minutes = (offset.abs % 3600) / 60
|
|
145
|
-
format("%s%02d%02d", sign, hours, minutes)
|
|
81
|
+
def self.open_repository(repo_path)
|
|
82
|
+
require "rugged"
|
|
83
|
+
Rugged::Repository.new(repo_path)
|
|
146
84
|
end
|
|
147
85
|
|
|
148
|
-
def self.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
lines = raw_data.split("\n")
|
|
153
|
-
|
|
154
|
-
extra_headers = []
|
|
155
|
-
in_headers = true
|
|
156
|
-
|
|
157
|
-
lines.each do |line|
|
|
158
|
-
# Stop when we hit the blank line before the message
|
|
159
|
-
if line.empty?
|
|
160
|
-
in_headers = false
|
|
161
|
-
next
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
next unless in_headers
|
|
165
|
-
|
|
166
|
-
# Skip standard headers
|
|
167
|
-
next if line.start_with?("tree ", "parent ", "author ", "committer ")
|
|
168
|
-
|
|
169
|
-
# Extract extra headers (like gpgsig, mergetag, svn-repo-uuid, etc)
|
|
170
|
-
if line.start_with?(" ")
|
|
171
|
-
# Continuation of previous header
|
|
172
|
-
if extra_headers.any?
|
|
173
|
-
extra_headers.last[1] += "\n#{line[1..]}"
|
|
174
|
-
end
|
|
175
|
-
elsif line.include?(" ")
|
|
176
|
-
key, value = line.split(" ", 2)
|
|
177
|
-
extra_headers << [key, value]
|
|
178
|
-
end
|
|
86
|
+
def self.verify_git_object!(repo, oid, expected_type)
|
|
87
|
+
object = repo.read(oid)
|
|
88
|
+
unless object.type == expected_type
|
|
89
|
+
raise ValidationError, "Expected #{expected_type} object, found #{object.type}"
|
|
179
90
|
end
|
|
180
91
|
|
|
181
|
-
|
|
92
|
+
digest = Digest::SHA1.new
|
|
93
|
+
digest.update("#{object.type} #{object.len}\0")
|
|
94
|
+
digest.update(object.data)
|
|
95
|
+
raise ValidationError, "Git object hash mismatch: #{oid}" unless digest.hexdigest == oid
|
|
182
96
|
end
|
|
183
97
|
|
|
184
|
-
def self.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
lines.each do |line|
|
|
193
|
-
# Stop when we hit the blank line before the message
|
|
194
|
-
if line.empty?
|
|
195
|
-
in_headers = false
|
|
196
|
-
next
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
next unless in_headers
|
|
200
|
-
|
|
201
|
-
# Skip standard tag headers
|
|
202
|
-
next if line.start_with?("object ", "type ", "tag ", "tagger ")
|
|
203
|
-
|
|
204
|
-
# Extract extra headers (like gpgsig for signed tags)
|
|
205
|
-
if line.start_with?(" ")
|
|
206
|
-
# Continuation of previous header
|
|
207
|
-
if extra_headers.any?
|
|
208
|
-
extra_headers.last[1] += "\n#{line[1..]}"
|
|
209
|
-
end
|
|
210
|
-
elsif line.include?(" ")
|
|
211
|
-
key, value = line.split(" ", 2)
|
|
212
|
-
extra_headers << [key, value]
|
|
213
|
-
end
|
|
98
|
+
def self.reference_target_type(repo, oid)
|
|
99
|
+
case repo.read_header(oid)[:type]
|
|
100
|
+
when :commit then "revision"
|
|
101
|
+
when :tag then "release"
|
|
102
|
+
when :tree then "directory"
|
|
103
|
+
when :blob then "content"
|
|
104
|
+
else "revision"
|
|
214
105
|
end
|
|
215
|
-
|
|
216
|
-
extra_headers
|
|
217
106
|
end
|
|
218
107
|
end
|
|
219
108
|
end
|
data/lib/swhid/identifier.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
module Swhid
|
|
6
4
|
class Identifier
|
|
7
5
|
attr_reader :scheme, :version, :object_type, :object_hash, :qualifiers
|
|
@@ -11,13 +9,13 @@ module Swhid
|
|
|
11
9
|
@version = SCHEME_VERSION
|
|
12
10
|
@object_type = validate_object_type!(object_type)
|
|
13
11
|
@object_hash = validate_object_hash!(object_hash)
|
|
14
|
-
@qualifiers = qualifiers
|
|
12
|
+
@qualifiers = validate_qualifiers!(qualifiers)
|
|
15
13
|
end
|
|
16
14
|
|
|
17
15
|
def self.parse(swhid_string)
|
|
18
16
|
raise ParseError, "SWHID string cannot be nil or empty" if swhid_string.nil? || swhid_string.empty?
|
|
19
17
|
|
|
20
|
-
core_part,
|
|
18
|
+
core_part, qualifier_string = swhid_string.split(";", 2)
|
|
21
19
|
|
|
22
20
|
parts = core_part.split(":")
|
|
23
21
|
raise ParseError, "Invalid SWHID format" unless parts.length == 4
|
|
@@ -27,7 +25,7 @@ module Swhid
|
|
|
27
25
|
raise ParseError, "Invalid scheme: #{scheme}" unless scheme == SCHEME
|
|
28
26
|
raise ParseError, "Invalid version: #{version}" unless version == SCHEME_VERSION.to_s
|
|
29
27
|
|
|
30
|
-
qualifiers = parse_qualifiers(
|
|
28
|
+
qualifiers = parse_qualifiers(qualifier_string)
|
|
31
29
|
|
|
32
30
|
new(object_type: object_type, object_hash: object_hash, qualifiers: qualifiers)
|
|
33
31
|
end
|
|
@@ -74,21 +72,32 @@ module Swhid
|
|
|
74
72
|
hash
|
|
75
73
|
end
|
|
76
74
|
|
|
77
|
-
def self.parse_qualifiers(
|
|
75
|
+
def self.parse_qualifiers(qualifier_string)
|
|
78
76
|
qualifiers = {}
|
|
77
|
+
return qualifiers unless qualifier_string
|
|
78
|
+
|
|
79
|
+
qualifier_string.split(";", -1).each do |part|
|
|
80
|
+
next if part.empty?
|
|
79
81
|
|
|
80
|
-
qualifier_parts.each do |part|
|
|
81
82
|
key, value = part.split("=", 2)
|
|
82
|
-
|
|
83
|
+
raise ParseError, "Invalid qualifier: #{part}" if key.nil? || key.empty? || value.nil?
|
|
83
84
|
|
|
84
|
-
qualifiers[key.to_sym] =
|
|
85
|
+
qualifiers[key.to_sym] = if key == "origin" || key == "path"
|
|
86
|
+
decode_qualifier_value(key, value)
|
|
87
|
+
else
|
|
88
|
+
value
|
|
89
|
+
end
|
|
85
90
|
end
|
|
86
91
|
|
|
87
92
|
qualifiers
|
|
88
93
|
end
|
|
89
94
|
|
|
90
|
-
def self.decode_qualifier_value(value)
|
|
91
|
-
|
|
95
|
+
def self.decode_qualifier_value(key, value)
|
|
96
|
+
decoded = value.gsub(/%([0-9a-fA-F]{2})/) { [$1].pack("H2") }
|
|
97
|
+
decoded.force_encoding(Encoding::UTF_8)
|
|
98
|
+
return decoded if decoded.valid_encoding?
|
|
99
|
+
|
|
100
|
+
raise ParseError, "Invalid UTF-8 in #{key} qualifier"
|
|
92
101
|
end
|
|
93
102
|
|
|
94
103
|
def format_qualifiers(quals)
|
|
@@ -97,18 +106,71 @@ module Swhid
|
|
|
97
106
|
ordered_quals = canonical_order.map do |key|
|
|
98
107
|
next unless quals.key?(key)
|
|
99
108
|
|
|
100
|
-
|
|
109
|
+
value = quals[key]
|
|
110
|
+
value = encode_qualifier_value(value) if key == :origin || key == :path
|
|
111
|
+
"#{key}=#{value}"
|
|
101
112
|
end.compact
|
|
102
113
|
|
|
103
114
|
other_quals = quals.reject { |key, _| canonical_order.include?(key) }.map do |key, value|
|
|
104
|
-
"#{key}=#{
|
|
115
|
+
"#{key}=#{value}"
|
|
105
116
|
end
|
|
106
117
|
|
|
107
118
|
(ordered_quals + other_quals).join(";")
|
|
108
119
|
end
|
|
109
120
|
|
|
110
121
|
def encode_qualifier_value(value)
|
|
111
|
-
value.to_s.gsub(";", "%3B")
|
|
122
|
+
value.to_s.gsub(";", "%3B")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_qualifiers!(qualifiers)
|
|
126
|
+
unless qualifiers.respond_to?(:each_pair)
|
|
127
|
+
raise ValidationError, "Qualifiers must be a hash"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
qualifiers.each_pair.each_with_object({}) do |(key, value), validated|
|
|
131
|
+
key = key.to_s
|
|
132
|
+
unless key.match?(/\A[^;=]+\z/)
|
|
133
|
+
raise ValidationError, "Invalid qualifier key: #{key}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
validated[key.to_sym] = normalize_qualifier_value(key, value)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normalize_qualifier_value(key, value)
|
|
141
|
+
case key
|
|
142
|
+
when "origin", "path"
|
|
143
|
+
string = value.to_s
|
|
144
|
+
raise ValidationError, "Invalid UTF-8 in #{key} qualifier" unless string.valid_encoding?
|
|
145
|
+
string
|
|
146
|
+
when "visit", "anchor"
|
|
147
|
+
parsed = value.is_a?(Identifier) ? value : Identifier.parse(value.to_s)
|
|
148
|
+
unless parsed.qualifiers.empty?
|
|
149
|
+
raise ValidationError, "Invalid #{key} qualifier: expected a core SWHID"
|
|
150
|
+
end
|
|
151
|
+
parsed.core_swhid
|
|
152
|
+
when "lines", "bytes"
|
|
153
|
+
normalize_range_qualifier!(key, value.to_s)
|
|
154
|
+
else
|
|
155
|
+
value
|
|
156
|
+
end
|
|
157
|
+
rescue ParseError, ValidationError
|
|
158
|
+
raise ValidationError, "Invalid #{key} qualifier: #{value}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def normalize_range_qualifier!(key, value)
|
|
162
|
+
match = /\A(\d+)(?:-(\d+))?\z/.match(value)
|
|
163
|
+
raise ValidationError, "Invalid #{key} qualifier: #{value}" unless match
|
|
164
|
+
|
|
165
|
+
start_position = Integer(match[1], 10)
|
|
166
|
+
end_position = match[2] && Integer(match[2], 10)
|
|
167
|
+
max_position = (2**64) - 1
|
|
168
|
+
|
|
169
|
+
if start_position > max_position || (end_position && (end_position < start_position || end_position > max_position))
|
|
170
|
+
raise ValidationError, "Invalid #{key} qualifier: #{value}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
end_position ? "#{start_position}-#{end_position}" : start_position.to_s
|
|
112
174
|
end
|
|
113
175
|
end
|
|
114
176
|
end
|
|
@@ -5,14 +5,41 @@ require "digest/sha1"
|
|
|
5
5
|
module Swhid
|
|
6
6
|
module Objects
|
|
7
7
|
class Content
|
|
8
|
+
READ_SIZE = 64 * 1024
|
|
9
|
+
|
|
8
10
|
def self.compute(data)
|
|
9
11
|
data = data.to_s if data.is_a?(Symbol)
|
|
10
|
-
data = data.b if data.respond_to?(:b)
|
|
11
12
|
|
|
12
13
|
header = "blob #{data.bytesize}\0"
|
|
13
|
-
|
|
14
|
+
digest = Digest::SHA1.new
|
|
15
|
+
digest.update(header)
|
|
16
|
+
digest.update(data)
|
|
17
|
+
|
|
18
|
+
Identifier.new(object_type: "cnt", object_hash: digest.hexdigest)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.compute_io(io, size:)
|
|
22
|
+
unless size.is_a?(Integer) && size >= 0
|
|
23
|
+
raise ArgumentError, "Content size must be a non-negative integer"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
digest = Digest::SHA1.new
|
|
27
|
+
digest.update("blob #{size}\0")
|
|
28
|
+
|
|
29
|
+
bytes_read = 0
|
|
30
|
+
buffer = String.new(capacity: READ_SIZE, encoding: Encoding::BINARY)
|
|
31
|
+
while io.read(READ_SIZE, buffer)
|
|
32
|
+
break if buffer.empty?
|
|
33
|
+
|
|
34
|
+
bytes_read += buffer.bytesize
|
|
35
|
+
digest.update(buffer)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
unless bytes_read == size
|
|
39
|
+
raise ArgumentError, "Content size is #{bytes_read} bytes, expected #{size}"
|
|
40
|
+
end
|
|
14
41
|
|
|
15
|
-
Identifier.new(object_type: "cnt", object_hash:
|
|
42
|
+
Identifier.new(object_type: "cnt", object_hash: digest.hexdigest)
|
|
16
43
|
end
|
|
17
44
|
|
|
18
45
|
def self.compute_hash(data)
|
|
@@ -9,10 +9,14 @@ module Swhid
|
|
|
9
9
|
attr_reader :name, :type, :target, :perms
|
|
10
10
|
|
|
11
11
|
def initialize(name:, type:, target:, perms: nil)
|
|
12
|
-
@name = name
|
|
12
|
+
@name = validate_name!(name)
|
|
13
13
|
@type = type
|
|
14
14
|
@target = target
|
|
15
15
|
@perms = perms || default_perms
|
|
16
|
+
@name_binary = @name.b.freeze
|
|
17
|
+
@perms_binary = @perms.to_s.b.freeze
|
|
18
|
+
@target_hash = pack_target_hash(target).freeze
|
|
19
|
+
@sort_key = type == :dir ? @name_binary + "/".b : @name_binary
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def default_perms
|
|
@@ -33,28 +37,52 @@ module Swhid
|
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
def sort_key
|
|
36
|
-
|
|
40
|
+
@sort_key
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
def target_hash
|
|
40
|
-
|
|
44
|
+
@target_hash
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def name_binary
|
|
48
|
+
@name_binary
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def perms_binary
|
|
52
|
+
@perms_binary
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pack_target_hash(value)
|
|
56
|
+
case value
|
|
41
57
|
when String
|
|
42
|
-
|
|
43
|
-
|
|
58
|
+
unless value.match?(/\A[0-9a-f]{#{OBJECT_ID_LENGTH}}\z/)
|
|
59
|
+
raise ValidationError, "Invalid target hash"
|
|
60
|
+
end
|
|
61
|
+
[value].pack("H*")
|
|
44
62
|
when Identifier
|
|
45
|
-
[
|
|
63
|
+
[value.object_hash].pack("H*")
|
|
46
64
|
else
|
|
47
65
|
raise ValidationError, "Invalid target type"
|
|
48
66
|
end
|
|
49
67
|
end
|
|
68
|
+
|
|
69
|
+
def validate_name!(value)
|
|
70
|
+
raise ValidationError, "Directory entry name must be a string" unless value.is_a?(String)
|
|
71
|
+
raise ValidationError, "Directory entry name cannot contain a null byte" if value.include?("\0")
|
|
72
|
+
raise ValidationError, "Directory entry name cannot contain a slash" if value.include?("/")
|
|
73
|
+
|
|
74
|
+
value
|
|
75
|
+
end
|
|
50
76
|
end
|
|
51
77
|
|
|
52
78
|
def self.compute(entries)
|
|
53
79
|
serialized = serialize_entries(entries)
|
|
54
80
|
header = "tree #{serialized.bytesize}\0"
|
|
55
|
-
|
|
81
|
+
digest = Digest::SHA1.new
|
|
82
|
+
digest.update(header)
|
|
83
|
+
digest.update(serialized)
|
|
56
84
|
|
|
57
|
-
Identifier.new(object_type: "dir", object_hash:
|
|
85
|
+
Identifier.new(object_type: "dir", object_hash: digest.hexdigest)
|
|
58
86
|
end
|
|
59
87
|
|
|
60
88
|
def self.serialize_entries(entries)
|
|
@@ -66,14 +94,22 @@ module Swhid
|
|
|
66
94
|
end
|
|
67
95
|
end
|
|
68
96
|
|
|
97
|
+
entry_names = {}
|
|
98
|
+
entries.each do |entry|
|
|
99
|
+
name = entry.name_binary
|
|
100
|
+
raise ValidationError, "Duplicate directory entry name: #{entry.name}" if entry_names.key?(name)
|
|
101
|
+
|
|
102
|
+
entry_names[name] = true
|
|
103
|
+
end
|
|
104
|
+
|
|
69
105
|
sorted_entries = entries.sort_by(&:sort_key)
|
|
70
106
|
|
|
71
|
-
sorted_entries.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
perms_binary
|
|
75
|
-
|
|
76
|
-
|
|
107
|
+
capacity = sorted_entries.sum { |entry| entry.perms_binary.bytesize + entry.name_binary.bytesize + 22 }
|
|
108
|
+
serialized = String.new(capacity: capacity, encoding: Encoding::BINARY)
|
|
109
|
+
sorted_entries.each do |entry|
|
|
110
|
+
serialized << entry.perms_binary << 32 << entry.name_binary << 0 << entry.target_hash
|
|
111
|
+
end
|
|
112
|
+
serialized
|
|
77
113
|
end
|
|
78
114
|
|
|
79
115
|
def self.compute_hash(entries)
|
|
@@ -16,9 +16,11 @@ module Swhid
|
|
|
16
16
|
def self.compute(metadata)
|
|
17
17
|
serialized = serialize_metadata(metadata)
|
|
18
18
|
header = "tag #{serialized.bytesize}\0"
|
|
19
|
-
|
|
19
|
+
digest = Digest::SHA1.new
|
|
20
|
+
digest.update(header)
|
|
21
|
+
digest.update(serialized)
|
|
20
22
|
|
|
21
|
-
Identifier.new(object_type: "rel", object_hash:
|
|
23
|
+
Identifier.new(object_type: "rel", object_hash: digest.hexdigest)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
def self.serialize_metadata(metadata)
|
|
@@ -8,9 +8,11 @@ module Swhid
|
|
|
8
8
|
def self.compute(metadata)
|
|
9
9
|
serialized = serialize_metadata(metadata)
|
|
10
10
|
header = "commit #{serialized.bytesize}\0"
|
|
11
|
-
|
|
11
|
+
digest = Digest::SHA1.new
|
|
12
|
+
digest.update(header)
|
|
13
|
+
digest.update(serialized)
|
|
12
14
|
|
|
13
|
-
Identifier.new(object_type: "rev", object_hash:
|
|
15
|
+
Identifier.new(object_type: "rev", object_hash: digest.hexdigest)
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def self.serialize_metadata(metadata)
|
|
@@ -9,7 +9,7 @@ module Swhid
|
|
|
9
9
|
attr_reader :name, :target_type, :target
|
|
10
10
|
|
|
11
11
|
def initialize(name:, target_type:, target: nil)
|
|
12
|
-
@name = name
|
|
12
|
+
@name = validate_name!(name)
|
|
13
13
|
@target_type = target_type
|
|
14
14
|
@target = target
|
|
15
15
|
end
|
|
@@ -23,6 +23,13 @@ module Swhid
|
|
|
23
23
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
|
+
def validate_name!(value)
|
|
27
|
+
raise ValidationError, "Snapshot branch name must be a string" unless value.is_a?(String)
|
|
28
|
+
raise ValidationError, "Snapshot branch name cannot contain a null byte" if value.include?("\0")
|
|
29
|
+
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
26
33
|
def compute_target_identifier
|
|
27
34
|
case target_type
|
|
28
35
|
when "content", "directory", "revision", "release", "snapshot"
|
|
@@ -39,7 +46,7 @@ module Swhid
|
|
|
39
46
|
def extract_hash_bytes(value)
|
|
40
47
|
hash_string = case value
|
|
41
48
|
when String
|
|
42
|
-
value.
|
|
49
|
+
value.match?(/\A[0-9a-f]{#{OBJECT_ID_LENGTH}}\z/) ? value : nil
|
|
43
50
|
when Identifier
|
|
44
51
|
value.object_hash
|
|
45
52
|
else
|
|
@@ -55,9 +62,11 @@ module Swhid
|
|
|
55
62
|
def self.compute(branches)
|
|
56
63
|
serialized = serialize_branches(branches)
|
|
57
64
|
header = "snapshot #{serialized.bytesize}\0"
|
|
58
|
-
|
|
65
|
+
digest = Digest::SHA1.new
|
|
66
|
+
digest.update(header)
|
|
67
|
+
digest.update(serialized)
|
|
59
68
|
|
|
60
|
-
Identifier.new(object_type: "snp", object_hash:
|
|
69
|
+
Identifier.new(object_type: "snp", object_hash: digest.hexdigest)
|
|
61
70
|
end
|
|
62
71
|
|
|
63
72
|
def self.serialize_branches(branches)
|
|
@@ -69,7 +78,9 @@ module Swhid
|
|
|
69
78
|
end
|
|
70
79
|
end
|
|
71
80
|
|
|
72
|
-
sorted_branches = branch_objects.sort_by
|
|
81
|
+
sorted_branches = branch_objects.sort_by { |branch| branch.name.b }
|
|
82
|
+
duplicate = sorted_branches.each_cons(2).find { |left, right| left.name.b == right.name.b }
|
|
83
|
+
raise ValidationError, "Duplicate snapshot branch name: #{duplicate.first.name}" if duplicate
|
|
73
84
|
|
|
74
85
|
sorted_branches.map(&:serialize).join
|
|
75
86
|
end
|
data/lib/swhid/version.rb
CHANGED
data/lib/swhid.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: swhid
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Nesbitt
|
|
@@ -33,6 +33,7 @@ executables:
|
|
|
33
33
|
extensions: []
|
|
34
34
|
extra_rdoc_files: []
|
|
35
35
|
files:
|
|
36
|
+
- ".ruby-version"
|
|
36
37
|
- CHANGELOG.md
|
|
37
38
|
- CODE_OF_CONDUCT.md
|
|
38
39
|
- LICENSE
|
|
@@ -73,7 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
73
74
|
- !ruby/object:Gem::Version
|
|
74
75
|
version: '0'
|
|
75
76
|
requirements: []
|
|
76
|
-
rubygems_version: 4.0.
|
|
77
|
+
rubygems_version: 4.0.20
|
|
77
78
|
specification_version: 4
|
|
78
79
|
summary: Generate and parse SoftWare Hash IDentifiers (SWHIDs)
|
|
79
80
|
test_files: []
|