swhid 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 36bebc03a91e8eecaa94a9dacb8496ab76fd8b2f2d4858b6088288a3b1195cbd
4
+ data.tar.gz: 71f64f03c285c8f445b5e5e74938d7d08141e91bc0cc914506fcdfb6dd970448
5
+ SHA512:
6
+ metadata.gz: 1aa17852be20170334a2d45cc7affc4d133e08eba03289a1e2269d0d0faec60b5367ed81396336ac4f26ea8d781bbb2bdf2cb60fac0db8f2ee0ff6f494ab30ed
7
+ data.tar.gz: 9fed39916ba99f2c3fa44c26fb266b7e465138c80aa200f368c6c89cc57c00b91c9753975688ff292ad47c30f2fa9c13c395526f439fb87c388253e2bd7c65a5
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-11-09
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # Swhid
2
+
3
+ A Ruby library and CLI for generating and parsing SoftWare Hash IDentifiers (SWHIDs).
4
+
5
+ SWHIDs are persistent, intrinsic identifiers for software artifacts such as files, directories, commits, releases, and snapshots. They are content-based identifiers that use Merkle DAGs for tamper-proof identification with built-in integrity verification.
6
+
7
+ This implementation follows the official [SWHID specification v1.2](https://www.swhid.org/specification) (ISO/IEC 18670:2025).
8
+
9
+ ## Features
10
+
11
+ - Generate SWHIDs for all object types:
12
+ - Content (cnt) - files and blobs
13
+ - Directory (dir) - directory trees
14
+ - Revision (rev) - commits
15
+ - Release (rel) - tags and releases
16
+ - Snapshot (snp) - repository snapshots
17
+ - Parse and validate SWHID strings
18
+ - Support for qualifiers (origin, visit, anchor, path, lines, bytes)
19
+ - Command-line interface for easy integration
20
+ - Git-compatible hash computation
21
+ - Comprehensive test suite
22
+
23
+ ## Installation
24
+
25
+ Add this line to your application's Gemfile:
26
+
27
+ ```ruby
28
+ gem 'swhid'
29
+ ```
30
+
31
+ And then execute:
32
+
33
+ ```bash
34
+ bundle install
35
+ ```
36
+
37
+ Or install it yourself as:
38
+
39
+ ```bash
40
+ gem install swhid
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ### Library Usage
46
+
47
+ #### Parsing SWHIDs
48
+
49
+ ```ruby
50
+ require 'swhid'
51
+
52
+ # Parse a SWHID string
53
+ swhid = Swhid.parse("swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2")
54
+
55
+ puts swhid.scheme # => "swh"
56
+ puts swhid.version # => 1
57
+ puts swhid.object_type # => "cnt"
58
+ puts swhid.object_hash # => "94a9ed024d3859793618152ea559a168bbcbb5e2"
59
+ puts swhid.to_s # => "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2"
60
+
61
+ # Parse SWHID with qualifiers
62
+ swhid = Swhid.parse("swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2;origin=https://github.com/example/repo;lines=5-10")
63
+ puts swhid.qualifiers[:origin] # => "https://github.com/example/repo"
64
+ puts swhid.qualifiers[:lines] # => "5-10"
65
+ ```
66
+
67
+ #### Generating SWHIDs
68
+
69
+ **Content (Files)**
70
+
71
+ ```ruby
72
+ # From a file
73
+ content = File.read("example.txt")
74
+ swhid = Swhid.from_content(content)
75
+ puts swhid.to_s # => "swh:1:cnt:..."
76
+
77
+ # Empty file
78
+ swhid = Swhid.from_content("")
79
+ puts swhid.to_s # => "swh:1:cnt:e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
80
+ ```
81
+
82
+ **Directory**
83
+
84
+ ```ruby
85
+ entries = [
86
+ { name: "README.md", type: :file, target: "94a9ed024d3859793618152ea559a168bbcbb5e2" },
87
+ { name: "src", type: :dir, target: "4b825dc642cb6eb9a060e54bf8d69288fbee4904" },
88
+ { name: "script.sh", type: :exec, target: "84a9ed024d3859793618152ea559a168bbcbb5e1" }
89
+ ]
90
+
91
+ swhid = Swhid.from_directory(entries)
92
+ puts swhid.to_s
93
+ ```
94
+
95
+ **Revision (Commit)**
96
+
97
+ ```ruby
98
+ metadata = {
99
+ directory: "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
100
+ author: "John Doe <john@example.com>",
101
+ author_timestamp: 1234567890,
102
+ author_timezone: "+0000",
103
+ committer: "Jane Smith <jane@example.com>",
104
+ committer_timestamp: 1234567890,
105
+ committer_timezone: "+0000",
106
+ message: "Initial commit",
107
+ parents: [] # Optional
108
+ }
109
+
110
+ swhid = Swhid.from_revision(metadata)
111
+ puts swhid.to_s
112
+ ```
113
+
114
+ **Release (Tag)**
115
+
116
+ ```ruby
117
+ metadata = {
118
+ name: "v1.0.0",
119
+ target: { hash: "94a9ed024d3859793618152ea559a168bbcbb5e2", type: "rev" },
120
+ author: "John Doe <john@example.com>",
121
+ author_timestamp: 1234567890,
122
+ message: "Release version 1.0.0"
123
+ }
124
+
125
+ swhid = Swhid.from_release(metadata)
126
+ puts swhid.to_s
127
+ ```
128
+
129
+ **Snapshot**
130
+
131
+ ```ruby
132
+ branches = [
133
+ { name: "refs/heads/main", target_type: "revision", target: "94a9ed024d3859793618152ea559a168bbcbb5e2" },
134
+ { name: "refs/tags/v1.0", target_type: "release", target: "84a9ed024d3859793618152ea559a168bbcbb5e1" },
135
+ { name: "HEAD", target_type: "alias", target: "refs/heads/main" }
136
+ ]
137
+
138
+ swhid = Swhid.from_snapshot(branches)
139
+ puts swhid.to_s
140
+ ```
141
+
142
+ **Working with Qualifiers**
143
+
144
+ ```ruby
145
+ # Create SWHID with qualifiers
146
+ swhid = Swhid::Identifier.new(
147
+ object_type: "cnt",
148
+ object_hash: "94a9ed024d3859793618152ea559a168bbcbb5e2",
149
+ qualifiers: {
150
+ origin: "https://github.com/example/repo",
151
+ visit: "swh:1:snp:d7f1b9eb7ccb596c2622c4780febaa02549830f9",
152
+ anchor: "swh:1:rev:2db189928c94d62a3b4757b3eec68f0a4d4113f0",
153
+ path: "/src/main.rb",
154
+ lines: "10-20"
155
+ }
156
+ )
157
+
158
+ puts swhid.to_s
159
+ # => "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2;origin=https://github.com/example/repo;visit=swh:1:snp:...;anchor=swh:1:rev:...;path=/src/main.rb;lines=10-20"
160
+ ```
161
+
162
+ ### CLI Usage
163
+
164
+ The gem includes a command-line tool for working with SWHIDs:
165
+
166
+ **Parse a SWHID**
167
+
168
+ ```bash
169
+ $ swhid parse "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2"
170
+ SWHID: swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2
171
+ Core: swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2
172
+ Type: cnt
173
+ Hash: 94a9ed024d3859793618152ea559a168bbcbb5e2
174
+ ```
175
+
176
+ **Generate SWHID from file content**
177
+
178
+ ```bash
179
+ $ cat file.txt | swhid content
180
+ swh:1:cnt:9daeafb9864cf43055ae93beb0afd6c7d144bfa4
181
+
182
+ $ echo "Hello, World!" | swhid content
183
+ swh:1:cnt:96898574d1b88e619be24fd90bb4cd399acbc5ca
184
+ ```
185
+
186
+ **Add qualifiers**
187
+
188
+ ```bash
189
+ $ cat file.txt | swhid content -q origin=https://github.com/example/repo -q lines=1-10
190
+ swh:1:cnt:9daeafb9864cf43055ae93beb0afd6c7d144bfa4;origin=https://github.com/example/repo;lines=1-10
191
+ ```
192
+
193
+ **JSON output**
194
+
195
+ ```bash
196
+ $ swhid parse "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2" -f json
197
+ {
198
+ "swhid": "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2",
199
+ "core": "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2",
200
+ "object_type": "cnt",
201
+ "object_hash": "94a9ed024d3859793618152ea559a168bbcbb5e2",
202
+ "qualifiers": {}
203
+ }
204
+ ```
205
+
206
+ ## Object Types
207
+
208
+ - **cnt** (content): Individual files or blobs
209
+ - **dir** (directory): Directory trees with entries
210
+ - **rev** (revision): Git commits or equivalent
211
+ - **rel** (release): Tags or releases
212
+ - **snp** (snapshot): Repository snapshots at a point in time
213
+
214
+ ## Qualifiers
215
+
216
+ SWHIDs can include optional qualifiers to provide context:
217
+
218
+ - **origin**: URL of the software origin
219
+ - **visit**: Core SWHID of the snapshot when visited
220
+ - **anchor**: Core SWHID of the anchor node (directory, revision, release, or snapshot)
221
+ - **path**: Absolute file path from the root directory
222
+ - **lines**: Line range (e.g., "10-20")
223
+ - **bytes**: Byte range (e.g., "100-500")
224
+
225
+ ## Git Compatibility
226
+
227
+ The hash computation for content, directory, revision, and release objects is compatible with Git's object hashing. This means you can use this gem to compute the same hashes that Git would produce for the same objects.
228
+
229
+ ## Development
230
+
231
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
232
+
233
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
234
+
235
+ ## Contributing
236
+
237
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/swhid. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/swhid/blob/main/CODE_OF_CONDUCT.md).
238
+
239
+ ## Code of Conduct
240
+
241
+ Everyone interacting in the Swhid project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/swhid/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
data/exe/swhid ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "swhid"
6
+ require "optparse"
7
+ require "json"
8
+
9
+ class SwhidCLI
10
+ def initialize(args)
11
+ @args = args
12
+ @options = {
13
+ type: nil,
14
+ format: "text",
15
+ qualifiers: {}
16
+ }
17
+ end
18
+
19
+ def run
20
+ parse_options!
21
+
22
+ case @command
23
+ when "parse"
24
+ parse_swhid
25
+ when "content"
26
+ compute_content_swhid
27
+ when "help"
28
+ show_help
29
+ else
30
+ show_help
31
+ end
32
+ rescue => e
33
+ puts "Error: #{e.message}"
34
+ exit 1
35
+ end
36
+
37
+ private
38
+
39
+ def parse_options!
40
+ parser = OptionParser.new do |opts|
41
+ opts.banner = "Usage: swhid [command] [options]"
42
+ opts.separator ""
43
+ opts.separator "Commands:"
44
+ opts.separator " parse <swhid> Parse and validate a SWHID"
45
+ opts.separator " content Generate SWHID for content from stdin"
46
+ opts.separator " help Show this help message"
47
+ opts.separator ""
48
+ opts.separator "Options:"
49
+
50
+ opts.on("-f", "--format FORMAT", "Output format (text, json)") do |format|
51
+ @options[:format] = format
52
+ end
53
+
54
+ opts.on("-q", "--qualifier KEY=VALUE", "Add qualifier") do |qual|
55
+ key, value = qual.split("=", 2)
56
+ @options[:qualifiers][key.to_sym] = value
57
+ end
58
+
59
+ opts.on("-h", "--help", "Show this help") do
60
+ puts opts
61
+ exit
62
+ end
63
+ end
64
+
65
+ parser.parse!(@args)
66
+ @command = @args.shift || "help"
67
+ end
68
+
69
+ def parse_swhid
70
+ swhid_string = @args.shift
71
+ unless swhid_string
72
+ puts "Error: SWHID string required"
73
+ exit 1
74
+ end
75
+
76
+ swhid = Swhid.parse(swhid_string)
77
+
78
+ case @options[:format]
79
+ when "json"
80
+ output_json(swhid)
81
+ else
82
+ output_text(swhid)
83
+ end
84
+ end
85
+
86
+ def compute_content_swhid
87
+ content = $stdin.read
88
+
89
+ swhid = Swhid.from_content(content)
90
+
91
+ unless @options[:qualifiers].empty?
92
+ swhid = Swhid::Identifier.new(
93
+ object_type: swhid.object_type,
94
+ object_hash: swhid.object_hash,
95
+ qualifiers: @options[:qualifiers]
96
+ )
97
+ end
98
+
99
+ case @options[:format]
100
+ when "json"
101
+ output_json(swhid)
102
+ else
103
+ puts swhid.to_s
104
+ end
105
+ end
106
+
107
+ def output_text(swhid)
108
+ puts "SWHID: #{swhid.to_s}"
109
+ puts "Core: #{swhid.core_swhid}"
110
+ puts "Type: #{swhid.object_type}"
111
+ puts "Hash: #{swhid.object_hash}"
112
+
113
+ unless swhid.qualifiers.empty?
114
+ puts "Qualifiers:"
115
+ swhid.qualifiers.each do |key, value|
116
+ puts " #{key}: #{value}"
117
+ end
118
+ end
119
+ end
120
+
121
+ def output_json(swhid)
122
+ data = {
123
+ swhid: swhid.to_s,
124
+ core: swhid.core_swhid,
125
+ object_type: swhid.object_type,
126
+ object_hash: swhid.object_hash,
127
+ qualifiers: swhid.qualifiers
128
+ }
129
+ puts JSON.pretty_generate(data)
130
+ end
131
+
132
+ def show_help
133
+ puts <<~HELP
134
+ swhid - Generate and parse SoftWare Hash IDentifiers
135
+
136
+ Usage:
137
+ swhid parse <swhid> Parse and validate a SWHID
138
+ swhid content [options] Generate SWHID for content from stdin
139
+
140
+ Options:
141
+ -f, --format FORMAT Output format (text, json)
142
+ -q, --qualifier KEY=VALUE Add qualifier to generated SWHID
143
+ -h, --help Show this help
144
+
145
+ Examples:
146
+ # Parse a SWHID
147
+ swhid parse swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2
148
+
149
+ # Generate SWHID from file content
150
+ cat file.txt | swhid content
151
+
152
+ # Generate SWHID with qualifiers
153
+ cat file.txt | swhid content -q origin=https://github.com/example/repo
154
+
155
+ # Output as JSON
156
+ swhid parse swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2 -f json
157
+
158
+ For more information, visit: https://www.swhid.org/
159
+ HELP
160
+ end
161
+ end
162
+
163
+ SwhidCLI.new(ARGV).run
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Swhid
6
+ class Identifier
7
+ attr_reader :scheme, :version, :object_type, :object_hash, :qualifiers
8
+
9
+ def initialize(object_type:, object_hash:, qualifiers: {})
10
+ @scheme = SCHEME
11
+ @version = SCHEME_VERSION
12
+ @object_type = validate_object_type!(object_type)
13
+ @object_hash = validate_object_hash!(object_hash)
14
+ @qualifiers = qualifiers
15
+ end
16
+
17
+ def self.parse(swhid_string)
18
+ raise ParseError, "SWHID string cannot be nil or empty" if swhid_string.nil? || swhid_string.empty?
19
+
20
+ core_part, *qualifier_parts = swhid_string.split(";")
21
+
22
+ parts = core_part.split(":")
23
+ raise ParseError, "Invalid SWHID format" unless parts.length == 4
24
+
25
+ scheme, version, object_type, object_hash = parts
26
+
27
+ raise ParseError, "Invalid scheme: #{scheme}" unless scheme == SCHEME
28
+ raise ParseError, "Invalid version: #{version}" unless version == SCHEME_VERSION.to_s
29
+
30
+ qualifiers = parse_qualifiers(qualifier_parts)
31
+
32
+ new(object_type: object_type, object_hash: object_hash, qualifiers: qualifiers)
33
+ end
34
+
35
+ def to_s
36
+ core = "#{scheme}:#{version}:#{object_type}:#{object_hash}"
37
+ return core if qualifiers.empty?
38
+
39
+ qualifier_string = format_qualifiers(qualifiers)
40
+ "#{core};#{qualifier_string}"
41
+ end
42
+
43
+ def core_swhid
44
+ "#{scheme}:#{version}:#{object_type}:#{object_hash}"
45
+ end
46
+
47
+ def ==(other)
48
+ return false unless other.is_a?(Identifier)
49
+
50
+ core_swhid == other.core_swhid && qualifiers == other.qualifiers
51
+ end
52
+
53
+ def hash
54
+ [core_swhid, qualifiers].hash
55
+ end
56
+
57
+ def eql?(other)
58
+ self == other
59
+ end
60
+
61
+ private
62
+
63
+ def validate_object_type!(type)
64
+ unless VALID_OBJECT_TYPES.include?(type)
65
+ raise ValidationError, "Invalid object type: #{type}. Must be one of: #{VALID_OBJECT_TYPES.join(", ")}"
66
+ end
67
+ type
68
+ end
69
+
70
+ def validate_object_hash!(hash)
71
+ unless hash =~ /\A[0-9a-f]{#{OBJECT_ID_LENGTH}}\z/
72
+ raise ValidationError, "Invalid object hash: #{hash}. Must be #{OBJECT_ID_LENGTH} hex digits"
73
+ end
74
+ hash
75
+ end
76
+
77
+ def self.parse_qualifiers(qualifier_parts)
78
+ qualifiers = {}
79
+
80
+ qualifier_parts.each do |part|
81
+ key, value = part.split("=", 2)
82
+ next if key.nil? || value.nil?
83
+
84
+ qualifiers[key.to_sym] = decode_qualifier_value(value)
85
+ end
86
+
87
+ qualifiers
88
+ end
89
+
90
+ def self.decode_qualifier_value(value)
91
+ URI.decode_www_form_component(value)
92
+ end
93
+
94
+ def format_qualifiers(quals)
95
+ canonical_order = [:origin, :visit, :anchor, :path, :lines, :bytes]
96
+
97
+ ordered_quals = canonical_order.map do |key|
98
+ next unless quals.key?(key)
99
+
100
+ "#{key}=#{encode_qualifier_value(quals[key])}"
101
+ end.compact
102
+
103
+ other_quals = quals.reject { |key, _| canonical_order.include?(key) }.map do |key, value|
104
+ "#{key}=#{encode_qualifier_value(value)}"
105
+ end
106
+
107
+ (ordered_quals + other_quals).join(";")
108
+ end
109
+
110
+ def encode_qualifier_value(value)
111
+ value.to_s.gsub(";", "%3B").gsub("%", "%25")
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module Swhid
6
+ module Objects
7
+ class Content
8
+ def self.compute(data)
9
+ data = data.to_s if data.is_a?(Symbol)
10
+ data = data.b if data.respond_to?(:b)
11
+
12
+ header = "blob #{data.bytesize}\0"
13
+ hash = Digest::SHA1.hexdigest(header + data)
14
+
15
+ Identifier.new(object_type: "cnt", object_hash: hash)
16
+ end
17
+
18
+ def self.compute_hash(data)
19
+ compute(data).object_hash
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module Swhid
6
+ module Objects
7
+ class Directory
8
+ class Entry
9
+ attr_reader :name, :type, :target, :perms
10
+
11
+ def initialize(name:, type:, target:, perms: nil)
12
+ @name = name
13
+ @type = type
14
+ @target = target
15
+ @perms = perms || default_perms
16
+ end
17
+
18
+ def default_perms
19
+ case type
20
+ when :dir
21
+ "040000"
22
+ when :file
23
+ "100644"
24
+ when :exec
25
+ "100755"
26
+ when :symlink
27
+ "120000"
28
+ else
29
+ raise ValidationError, "Unknown entry type: #{type}"
30
+ end
31
+ end
32
+
33
+ def sort_key
34
+ type == :dir ? "#{name}/" : name
35
+ end
36
+
37
+ def target_hash
38
+ case target
39
+ when String
40
+ raise ValidationError, "Invalid hash length" unless target.length == 40
41
+ [target].pack("H*")
42
+ when Identifier
43
+ [target.object_id].pack("H*")
44
+ else
45
+ raise ValidationError, "Invalid target type"
46
+ end
47
+ end
48
+ end
49
+
50
+ def self.compute(entries)
51
+ serialized = serialize_entries(entries)
52
+ header = "tree #{serialized.bytesize}\0"
53
+ hash = Digest::SHA1.hexdigest(header + serialized)
54
+
55
+ Identifier.new(object_type: "dir", object_hash: hash)
56
+ end
57
+
58
+ def self.serialize_entries(entries)
59
+ entries = entries.map do |entry_data|
60
+ if entry_data.is_a?(Entry)
61
+ entry_data
62
+ else
63
+ Entry.new(**entry_data)
64
+ end
65
+ end
66
+
67
+ sorted_entries = entries.sort_by(&:sort_key)
68
+
69
+ sorted_entries.map do |entry|
70
+ "#{entry.perms} #{entry.name}\0#{entry.target_hash}"
71
+ end.join
72
+ end
73
+
74
+ def self.compute_hash(entries)
75
+ compute(entries).object_hash
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module Swhid
6
+ module Objects
7
+ class Release
8
+ OBJECT_TYPE_MAPPING = {
9
+ "cnt" => "blob",
10
+ "dir" => "tree",
11
+ "rev" => "commit",
12
+ "rel" => "tag",
13
+ "snp" => "snapshot"
14
+ }.freeze
15
+
16
+ def self.compute(metadata)
17
+ serialized = serialize_metadata(metadata)
18
+ header = "tag #{serialized.bytesize}\0"
19
+ hash = Digest::SHA1.hexdigest(header + serialized)
20
+
21
+ Identifier.new(object_type: "rel", object_hash: hash)
22
+ end
23
+
24
+ def self.serialize_metadata(metadata)
25
+ lines = []
26
+
27
+ raise ValidationError, "Name is required" unless metadata[:name]
28
+ raise ValidationError, "Target is required" unless metadata[:target]
29
+
30
+ target_hash, target_type = extract_target(metadata[:target])
31
+ lines << "object #{target_hash}"
32
+ lines << "type #{target_type}"
33
+ lines << "tag #{metadata[:name].gsub("\n", "\n ")}"
34
+
35
+ if metadata[:author]
36
+ raise ValidationError, "Author timestamp is required when author is present" unless metadata[:author_timestamp]
37
+
38
+ author_line = format_person_line("tagger", metadata[:author], metadata[:author_timestamp], metadata[:author_timezone])
39
+ lines << author_line
40
+ end
41
+
42
+ result = lines.join("\n") + "\n"
43
+
44
+ if metadata[:message]
45
+ result += "\n#{metadata[:message]}"
46
+ end
47
+
48
+ result
49
+ end
50
+
51
+ def self.format_person_line(prefix, person, timestamp, timezone)
52
+ tz = timezone || "+0000"
53
+ person_escaped = person.gsub("\n", "\n ")
54
+ "#{prefix} #{person_escaped} #{timestamp} #{tz}"
55
+ end
56
+
57
+ def self.extract_target(target)
58
+ case target
59
+ when Hash
60
+ hash = target[:hash] || target[:id]
61
+ type = target[:type]
62
+ raise ValidationError, "Target hash and type required" unless hash && type
63
+ [hash, OBJECT_TYPE_MAPPING[type] || type]
64
+ when Identifier
65
+ [target.object_hash, OBJECT_TYPE_MAPPING[target.object_type] || target.object_type]
66
+ else
67
+ raise ValidationError, "Invalid target format"
68
+ end
69
+ end
70
+
71
+ def self.compute_hash(metadata)
72
+ compute(metadata).object_hash
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module Swhid
6
+ module Objects
7
+ class Revision
8
+ def self.compute(metadata)
9
+ serialized = serialize_metadata(metadata)
10
+ header = "commit #{serialized.bytesize}\0"
11
+ hash = Digest::SHA1.hexdigest(header + serialized)
12
+
13
+ Identifier.new(object_type: "rev", object_hash: hash)
14
+ end
15
+
16
+ def self.serialize_metadata(metadata)
17
+ lines = []
18
+
19
+ directory = extract_hash(metadata[:directory])
20
+ raise ValidationError, "Directory is required" unless directory
21
+ lines << "tree #{directory}"
22
+
23
+ parents = Array(metadata[:parents] || [])
24
+ parents.each do |parent|
25
+ parent_hash = extract_hash(parent)
26
+ lines << "parent #{parent_hash}"
27
+ end
28
+
29
+ raise ValidationError, "Author is required" unless metadata[:author]
30
+ raise ValidationError, "Author timestamp is required" unless metadata[:author_timestamp]
31
+
32
+ author_line = format_person_line("author", metadata[:author], metadata[:author_timestamp], metadata[:author_timezone])
33
+ lines << author_line
34
+
35
+ raise ValidationError, "Committer is required" unless metadata[:committer]
36
+ raise ValidationError, "Committer timestamp is required" unless metadata[:committer_timestamp]
37
+
38
+ committer_line = format_person_line("committer", metadata[:committer], metadata[:committer_timestamp], metadata[:committer_timezone])
39
+ lines << committer_line
40
+
41
+ extra_headers = metadata[:extra_headers] || []
42
+ extra_headers.each do |key, value|
43
+ lines << format_header_line(key, value)
44
+ end
45
+
46
+ result = lines.join("\n") + "\n"
47
+
48
+ if metadata[:message]
49
+ result += "\n#{metadata[:message]}"
50
+ end
51
+
52
+ result
53
+ end
54
+
55
+ def self.format_person_line(prefix, person, timestamp, timezone)
56
+ tz = timezone || "+0000"
57
+ person_escaped = person.gsub("\n", "\n ")
58
+ "#{prefix} #{person_escaped} #{timestamp} #{tz}"
59
+ end
60
+
61
+ def self.format_header_line(key, value)
62
+ value_escaped = value.gsub("\n", "\n ")
63
+ "#{key} #{value_escaped}"
64
+ end
65
+
66
+ def self.extract_hash(value)
67
+ case value
68
+ when String
69
+ value.length == 40 ? value : nil
70
+ when Identifier
71
+ value.object_hash
72
+ else
73
+ nil
74
+ end
75
+ end
76
+
77
+ def self.compute_hash(metadata)
78
+ compute(metadata).object_hash
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module Swhid
6
+ module Objects
7
+ class Snapshot
8
+ class Branch
9
+ attr_reader :name, :target_type, :target
10
+
11
+ def initialize(name:, target_type:, target: nil)
12
+ @name = name
13
+ @target_type = target_type
14
+ @target = target
15
+ end
16
+
17
+ def serialize
18
+ target_identifier = compute_target_identifier
19
+ target_length = target_identifier.bytesize
20
+
21
+ "#{target_type} #{name}\0#{target_length}:#{target_identifier}"
22
+ end
23
+
24
+ private
25
+
26
+ def compute_target_identifier
27
+ case target_type
28
+ when "content", "directory", "revision", "release", "snapshot"
29
+ extract_hash_bytes(target)
30
+ when "alias"
31
+ target.to_s.b
32
+ when "dangling"
33
+ "".b
34
+ else
35
+ raise ValidationError, "Invalid target type: #{target_type}"
36
+ end
37
+ end
38
+
39
+ def extract_hash_bytes(value)
40
+ hash_string = case value
41
+ when String
42
+ value.length == 40 ? value : nil
43
+ when Identifier
44
+ value.object_hash
45
+ else
46
+ nil
47
+ end
48
+
49
+ raise ValidationError, "Invalid target hash" unless hash_string
50
+
51
+ [hash_string].pack("H*")
52
+ end
53
+ end
54
+
55
+ def self.compute(branches)
56
+ serialized = serialize_branches(branches)
57
+ header = "snapshot #{serialized.bytesize}\0"
58
+ hash = Digest::SHA1.hexdigest(header + serialized)
59
+
60
+ Identifier.new(object_type: "snp", object_hash: hash)
61
+ end
62
+
63
+ def self.serialize_branches(branches)
64
+ branch_objects = branches.map do |branch_data|
65
+ if branch_data.is_a?(Branch)
66
+ branch_data
67
+ else
68
+ Branch.new(**branch_data)
69
+ end
70
+ end
71
+
72
+ sorted_branches = branch_objects.sort_by(&:name)
73
+
74
+ sorted_branches.map(&:serialize).join
75
+ end
76
+
77
+ def self.compute_hash(branches)
78
+ compute(branches).object_hash
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Swhid
4
+ VERSION = "0.1.0"
5
+ end
data/lib/swhid.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "swhid/version"
4
+ require_relative "swhid/identifier"
5
+ require_relative "swhid/objects/content"
6
+ require_relative "swhid/objects/directory"
7
+ require_relative "swhid/objects/revision"
8
+ require_relative "swhid/objects/release"
9
+ require_relative "swhid/objects/snapshot"
10
+
11
+ module Swhid
12
+ class Error < StandardError; end
13
+ class ParseError < Error; end
14
+ class ValidationError < Error; end
15
+
16
+ SCHEME = "swh"
17
+ SCHEME_VERSION = 1
18
+ VALID_OBJECT_TYPES = %w[cnt dir rev rel snp].freeze
19
+ OBJECT_ID_LENGTH = 40
20
+
21
+ def self.parse(swhid_string)
22
+ Identifier.parse(swhid_string)
23
+ end
24
+
25
+ def self.from_content(content)
26
+ Objects::Content.compute(content)
27
+ end
28
+
29
+ def self.from_directory(entries)
30
+ Objects::Directory.compute(entries)
31
+ end
32
+
33
+ def self.from_revision(metadata)
34
+ Objects::Revision.compute(metadata)
35
+ end
36
+
37
+ def self.from_release(metadata)
38
+ Objects::Release.compute(metadata)
39
+ end
40
+
41
+ def self.from_snapshot(branches)
42
+ Objects::Snapshot.compute(branches)
43
+ end
44
+ end
data/sig/swhid.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Swhid
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: swhid
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Nesbitt
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A Ruby library and CLI for generating and parsing SoftWare Hash IDentifiers
13
+ (SWHIDs). Supports all object types (content, directory, revision, release, snapshot)
14
+ and qualifiers. Compatible with Git object hashing.
15
+ email:
16
+ - andrewnez@gmail.com
17
+ executables:
18
+ - swhid
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - CODE_OF_CONDUCT.md
24
+ - README.md
25
+ - Rakefile
26
+ - exe/swhid
27
+ - lib/swhid.rb
28
+ - lib/swhid/identifier.rb
29
+ - lib/swhid/objects/content.rb
30
+ - lib/swhid/objects/directory.rb
31
+ - lib/swhid/objects/release.rb
32
+ - lib/swhid/objects/revision.rb
33
+ - lib/swhid/objects/snapshot.rb
34
+ - lib/swhid/version.rb
35
+ - sig/swhid.rbs
36
+ homepage: https://github.com/andrew/swhid
37
+ licenses:
38
+ - MIT
39
+ metadata:
40
+ homepage_uri: https://github.com/andrew/swhid
41
+ source_code_uri: https://github.com/andrew/swhid
42
+ changelog_uri: https://github.com/andrew/swhid/blob/main/CHANGELOG.md
43
+ documentation_uri: https://www.swhid.org/specification
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: 3.2.0
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubygems_version: 3.6.9
59
+ specification_version: 4
60
+ summary: Generate and parse SoftWare Hash IDentifiers (SWHIDs)
61
+ test_files: []