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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55ef264a7547cae4c3e201c8c4b745ea42437173efd681623bf7307c0bf58b0d
4
- data.tar.gz: d851e19c5511018c5ded8e853306650414b1708a99fd10837f2e6d62a6266f13
3
+ metadata.gz: 5851a0a381d49b2824a6371b3cc4e1148ba8d8e6791b82d849feb5ba1860801b
4
+ data.tar.gz: bbb8211dea616259f3e80a5ea567d70fe3e74f44b0634f900cec78706a6913a2
5
5
  SHA512:
6
- metadata.gz: db250c4038a39aa01d3a73c83a1d11ebf907a7465649b69cbff3ed3c9b44e758519c2573ca949fabc996b43ae737a5259d41c1d143ecb1dc980813dc2d6c54cc
7
- data.tar.gz: abaa8a4ca6c33cc718c28f573641a207c6458c8dcb4df4440759583cd1dd9394a60c507c0d29795e4149218b1bcba8b212ee83114e1bbf6a6f0ebe99135328ef
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"
@@ -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
- entries = build_entries(path, git_repo: git_repo, permissions: permissions)
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
- entry = if File.symlink?(full_path)
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 = from_directory_path(full_path, git_repo: git_repo, permissions: permissions)
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?(full_path, stat, git_repo, permissions)
42
- content = File.binread(full_path)
43
- target_hash = Swhid.from_content(content).object_hash
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
- content = File.binread(full_path)
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.file_executable?(full_path, stat, git_repo, permissions = nil)
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
- relative_path = relative_path_in_repo(full_path, git_repo)
68
- if relative_path
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
- # Ensure repo_workdir ends with separator for proper prefix matching
90
- repo_workdir = repo_workdir.chomp("/").chomp("\\") + "/"
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
- return nil unless full_path.start_with?(repo_workdir)
147
+ repo_prefix = "#{repo_workdir}/"
148
+ return nil unless full_path.start_with?(repo_prefix)
93
149
 
94
- relative = full_path.sub(repo_workdir, "")
95
- relative.tr("\\", "/")
150
+ full_path.delete_prefix(repo_prefix)
96
151
  end
97
152
  end
98
153
  end
@@ -1,79 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rugged"
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 = Rugged::Repository.new(repo_path)
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
- metadata = {
14
- directory: commit.tree.oid,
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 = Rugged::Repository.new(repo_path)
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
- target_type = case tag_obj.target
44
- when Rugged::Commit then "rev"
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 = Rugged::Repository.new(repo_path)
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
- # Direct reference
107
- target_obj = ref.target
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.format_person(person)
137
- "#{person[:name]} <#{person[:email]}>"
138
- end
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.extract_extra_headers(repo, commit)
149
- # Rugged doesn't expose extra headers directly
150
- # We need to parse the raw commit object
151
- raw_data = repo.read(commit.oid).data
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
- extra_headers
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.extract_tag_extra_headers(repo, tag)
185
- # Parse raw tag object for extra headers
186
- raw_data = repo.read(tag.oid).data
187
- lines = raw_data.split("\n")
188
-
189
- extra_headers = []
190
- in_headers = true
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
@@ -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, *qualifier_parts = swhid_string.split(";")
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(qualifier_parts)
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(qualifier_parts)
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
- next if key.nil? || value.nil?
83
+ raise ParseError, "Invalid qualifier: #{part}" if key.nil? || key.empty? || value.nil?
83
84
 
84
- qualifiers[key.to_sym] = decode_qualifier_value(value)
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
- URI.decode_www_form_component(value)
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
- "#{key}=#{encode_qualifier_value(quals[key])}"
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}=#{encode_qualifier_value(value)}"
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").gsub("%", "%25")
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
- hash = Digest::SHA1.hexdigest(header + data)
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: 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
- type == :dir ? "#{name}/" : name
40
+ @sort_key
37
41
  end
38
42
 
39
43
  def target_hash
40
- case target
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
- raise ValidationError, "Invalid hash length" unless target.length == 40
43
- [target].pack("H*")
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
- [target.object_id].pack("H*")
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
- hash = Digest::SHA1.hexdigest(header + serialized)
81
+ digest = Digest::SHA1.new
82
+ digest.update(header)
83
+ digest.update(serialized)
56
84
 
57
- Identifier.new(object_type: "dir", object_hash: 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.map do |entry|
72
- # Convert name to binary UTF-8 to match target_hash encoding
73
- name_binary = entry.name.encode(Encoding::UTF_8).force_encoding(Encoding::BINARY)
74
- perms_binary = entry.perms.encode(Encoding::UTF_8).force_encoding(Encoding::BINARY)
75
- "#{perms_binary} #{name_binary}\0#{entry.target_hash}"
76
- end.join
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
- hash = Digest::SHA1.hexdigest(header + serialized)
19
+ digest = Digest::SHA1.new
20
+ digest.update(header)
21
+ digest.update(serialized)
20
22
 
21
- Identifier.new(object_type: "rel", object_hash: 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
- hash = Digest::SHA1.hexdigest(header + serialized)
11
+ digest = Digest::SHA1.new
12
+ digest.update(header)
13
+ digest.update(serialized)
12
14
 
13
- Identifier.new(object_type: "rev", object_hash: 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.length == 40 ? value : nil
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
- hash = Digest::SHA1.hexdigest(header + serialized)
65
+ digest = Digest::SHA1.new
66
+ digest.update(header)
67
+ digest.update(serialized)
59
68
 
60
- Identifier.new(object_type: "snp", object_hash: 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(&:name)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Swhid
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/swhid.rb CHANGED
@@ -28,6 +28,10 @@ module Swhid
28
28
  Objects::Content.compute(content)
29
29
  end
30
30
 
31
+ def self.from_content_io(io, size:)
32
+ Objects::Content.compute_io(io, size: size)
33
+ end
34
+
31
35
  def self.from_directory(entries)
32
36
  Objects::Directory.compute(entries)
33
37
  end
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.1
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.1
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: []