opa-ruby 0.1.0 → 0.1.3

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: 6938bad1dc3db52fbac52f2a3c9df453fb03a9d61f937a33f3280d4fb258fb46
4
- data.tar.gz: 25d52c72af1b58ff16e44f20bd4c49e955cfaa4f01a0002faea1498ac1ee2d2e
3
+ metadata.gz: cccc1723c3c1dc4be0dbc948517ec75ffead77445b2702ad1ef5f49eb84ebdaf
4
+ data.tar.gz: bdacfda13309b7e4ba9763365440394a60f68b3668c4d33161ded7772209e53c
5
5
  SHA512:
6
- metadata.gz: b7b3dc7a81c10bfc9e397bf94e4cf206a254db500a82040e4babedc36904cc9d9c42b806f93f69361efe544c16dcdd844a8f72c55e03df8fc5dbc25d0cabbf5b
7
- data.tar.gz: de0c56f213ecc9bb17edbb4d8bdd26323b5afe33bc71d98c4dfccb001578496f9d98a90ee7d13d936b04fe356105ed57356500552a340882a6c697216b8e97da
6
+ metadata.gz: 69061c10291cff9451a6d4cef0b5af43855b9da12034af01717f74af06e14cbbcc7da0c6e67e1bd08065e31c341d9fd25dc4283f29eb073d6a2e52aa1b551c26
7
+ data.tar.gz: 4f90b867a07ad4258d9b7e64262781e8afacfeecc5881983352e51e6329ffe88ae71e091f368784563fea99f3ce68a98231d23df6c1bf166e2b4193a4428bef8
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Hannah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # opa-ruby
2
+
3
+ A Ruby library and CLI for building, signing, and verifying [Open Prompt Archive (OPA)](https://github.com/shannah/opa-spec) files.
4
+
5
+ OPA archives are ZIP-based packages that bundle AI agent prompts with session history, data assets, and execution metadata into portable, distributable archives.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "opa-ruby"
13
+ ```
14
+
15
+ Or install directly:
16
+
17
+ ```bash
18
+ gem install opa-ruby
19
+ ```
20
+
21
+ **Zero external dependencies** — uses only Ruby stdlib (`zlib`, `openssl`).
22
+
23
+ ## Library Usage
24
+
25
+ ### Creating an archive
26
+
27
+ ```ruby
28
+ require "opa"
29
+
30
+ archive = OPA::Archive.new
31
+ archive.manifest["Title"] = "My Task"
32
+ archive.manifest["Execution-Mode"] = "batch"
33
+ archive.prompt = "Summarize the attached data."
34
+ archive.add_data("report.csv", File.read("report.csv"))
35
+
36
+ archive.write("my_task.opa")
37
+ ```
38
+
39
+ ### Signing an archive
40
+
41
+ ```ruby
42
+ key = OpenSSL::PKey::RSA.new(File.read("key.pem"))
43
+ cert = OpenSSL::X509::Certificate.new(File.read("cert.pem"))
44
+
45
+ archive = OPA::Archive.new
46
+ archive.prompt = "Do the thing."
47
+ archive.sign(key, cert, algorithm: "SHA-256")
48
+ archive.write("signed.opa")
49
+ ```
50
+
51
+ Supported digest algorithms: SHA-256, SHA-384, SHA-512. MD5 and SHA-1 are rejected per spec.
52
+
53
+ ### Reading and verifying an archive
54
+
55
+ ```ruby
56
+ verifier = OPA::Verifier.new("signed.opa")
57
+
58
+ verifier.signed? # => true
59
+ verifier.prompt # => "Do the thing."
60
+ verifier.manifest["Title"] # => "My Task"
61
+ verifier.data_entries # => { "data/report.csv" => "..." }
62
+
63
+ # Verify signature (raises OPA::SignatureError on failure)
64
+ cert = OpenSSL::X509::Certificate.new(File.read("cert.pem"))
65
+ verifier.verify!(certificate: cert)
66
+ ```
67
+
68
+ ## CLI Usage
69
+
70
+ The `opa` command provides three subcommands:
71
+
72
+ ### pack
73
+
74
+ Create an OPA archive from a prompt file.
75
+
76
+ ```bash
77
+ opa pack prompt.md # basic archive
78
+ opa pack prompt.md -t "My Task" -o task.opa # with title and output path
79
+ opa pack prompt.md -d data.csv -d config.json # with data files
80
+ opa pack prompt.md -s session.json # with session history
81
+ opa pack prompt.md -k key.pem -c cert.pem # signed archive
82
+ opa pack prompt.md -k key.pem -c cert.pem --algorithm SHA-512
83
+ ```
84
+
85
+ ### verify
86
+
87
+ Verify a signed archive's integrity.
88
+
89
+ ```bash
90
+ opa verify archive.opa # verify using embedded certificate
91
+ opa verify archive.opa -c cert.pem # verify against a specific certificate
92
+ ```
93
+
94
+ ### inspect
95
+
96
+ Display archive metadata and contents.
97
+
98
+ ```bash
99
+ opa inspect archive.opa
100
+ ```
101
+
102
+ ## Core Classes
103
+
104
+ | Class | Purpose |
105
+ |---|---|
106
+ | `OPA::Archive` | Builds `.opa` ZIP archives with manifest, prompt, data, and session entries |
107
+ | `OPA::Verifier` | Reads archives, extracts entries, and verifies signatures |
108
+ | `OPA::Signer` | JAR-format signing (SIGNATURE.SF + PKCS#7 block) |
109
+ | `OPA::Manifest` | META-INF/MANIFEST.MF parser and generator |
110
+ | `OPA::CLI` | Command-line interface |
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ bundle install
116
+ rake spec
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT
data/lib/opa/archive.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "zip"
4
3
  require "digest"
5
4
 
6
5
  module OPA
@@ -51,51 +50,42 @@ module OPA
51
50
  private
52
51
 
53
52
  def write_to_stream(io)
54
- buffer = StringIO.new
55
- build_zip(buffer)
56
- io.write(buffer.string)
57
- end
53
+ zip = ZipIO::Writer.new
58
54
 
59
- def build_zip(io)
60
- Zip::OutputStream.write_buffer(io) do |zip|
61
- # Compute digests for all entries if signing
62
- entry_digests = {}
63
- @entries.each do |path, content|
64
- data = content.is_a?(IO) ? content.read : content
65
- entry_digests[path] = data
66
- end
55
+ # Resolve all entry content
56
+ entry_data = {}
57
+ @entries.each do |path, content|
58
+ entry_data[path] = content.is_a?(IO) ? content.read : content
59
+ end
67
60
 
68
- # Build manifest with digests if signing
69
- if @signer
70
- entry_digests.each do |path, data|
71
- digest = @signer.digest(data)
72
- @manifest.add_entry(path, { "#{@signer.digest_name}-Digest" => digest })
73
- end
61
+ # Build manifest with digests if signing
62
+ if @signer
63
+ entry_data.each do |path, data|
64
+ digest = @signer.digest(data)
65
+ @manifest.add_entry(path, { "#{@signer.digest_name}-Digest" => digest })
74
66
  end
67
+ end
75
68
 
76
- manifest_content = @manifest.to_s
69
+ manifest_content = @manifest.to_s
77
70
 
78
- # Write manifest
79
- zip.put_next_entry("META-INF/MANIFEST.MF")
80
- zip.write(manifest_content)
71
+ # Write manifest
72
+ zip.add_entry("META-INF/MANIFEST.MF", manifest_content)
81
73
 
82
- # Write signature files if signing
83
- if @signer
84
- sf_content = @signer.signature_file(manifest_content)
85
- zip.put_next_entry("META-INF/SIGNATURE.SF")
86
- zip.write(sf_content)
74
+ # Write signature files if signing
75
+ if @signer
76
+ sf_content = @signer.signature_file(manifest_content)
77
+ zip.add_entry("META-INF/SIGNATURE.SF", sf_content)
87
78
 
88
- sig_block = @signer.signature_block(sf_content)
89
- zip.put_next_entry("META-INF/SIGNATURE#{@signer.block_extension}")
90
- zip.write(sig_block)
91
- end
79
+ sig_block = @signer.signature_block(sf_content)
80
+ zip.add_entry("META-INF/SIGNATURE#{@signer.block_extension}", sig_block)
81
+ end
92
82
 
93
- # Write all content entries
94
- entry_digests.each do |path, data|
95
- zip.put_next_entry(path)
96
- zip.write(data)
97
- end
83
+ # Write all content entries
84
+ entry_data.each do |path, data|
85
+ zip.add_entry(path, data)
98
86
  end
87
+
88
+ io.write(zip.to_bytes)
99
89
  end
100
90
  end
101
91
  end
data/lib/opa/verifier.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "zip"
4
3
  require "openssl"
5
4
  require "base64"
6
5
  require "digest"
@@ -16,11 +15,12 @@ module OPA
16
15
 
17
16
  def initialize(io_or_path)
18
17
  @raw_entries = {}
19
- if io_or_path.is_a?(String)
20
- File.open(io_or_path, "rb") { |f| read_zip(f) }
21
- else
22
- read_zip(io_or_path)
23
- end
18
+ data = if io_or_path.is_a?(String)
19
+ File.binread(io_or_path)
20
+ else
21
+ io_or_path.read
22
+ end
23
+ read_zip(data)
24
24
  end
25
25
 
26
26
  def signed?
@@ -98,15 +98,11 @@ module OPA
98
98
 
99
99
  private
100
100
 
101
- def read_zip(io)
102
- data = io.read
103
- Zip::InputStream.open(StringIO.new(data)) do |zip|
104
- while (entry = zip.get_next_entry)
105
- next if entry.directory?
106
- path = entry.name
107
- validate_path!(path)
108
- @raw_entries[path] = zip.read
109
- end
101
+ def read_zip(data)
102
+ entries = ZipIO::Reader.read(data)
103
+ entries.each do |path, content|
104
+ validate_path!(path)
105
+ @raw_entries[path] = content
110
106
  end
111
107
 
112
108
  manifest_content = @raw_entries["META-INF/MANIFEST.MF"]
data/lib/opa/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OPA
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/opa/zip_io.rb ADDED
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "stringio"
5
+
6
+ module OPA
7
+ # Minimal ZIP writer and reader using only Ruby stdlib (zlib).
8
+ # Supports the subset of ZIP needed for OPA/JAR archives: stored and
9
+ # deflated entries, no ZIP64 or encryption.
10
+ module ZipIO
11
+ # Builds a ZIP archive in memory and returns the bytes.
12
+ class Writer
13
+ Entry = Struct.new(:name, :data, :crc32, :compressed, :offset)
14
+
15
+ def initialize
16
+ @entries = []
17
+ end
18
+
19
+ def add_entry(name, data)
20
+ data = data.b
21
+ crc32 = Zlib.crc32(data)
22
+ deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS)
23
+ compressed = deflater.deflate(data, Zlib::FINISH)
24
+ deflater.close
25
+ @entries << Entry.new(name, data, crc32, compressed, nil)
26
+ end
27
+
28
+ def to_bytes
29
+ io = StringIO.new
30
+ io.set_encoding(Encoding::BINARY)
31
+
32
+ # Write local file headers + data
33
+ @entries.each do |entry|
34
+ entry.offset = io.pos
35
+ write_local_header(io, entry)
36
+ io.write(entry.compressed)
37
+ end
38
+
39
+ # Central directory
40
+ cd_offset = io.pos
41
+ @entries.each { |entry| write_cd_header(io, entry) }
42
+ cd_size = io.pos - cd_offset
43
+
44
+ # End of central directory
45
+ write_eocd(io, @entries.size, cd_size, cd_offset)
46
+
47
+ io.string
48
+ end
49
+
50
+ private
51
+
52
+ def write_local_header(io, entry)
53
+ name_bytes = entry.name.b
54
+ io.write([0x04034b50].pack("V")) # signature
55
+ io.write([20].pack("v")) # version needed
56
+ io.write([0].pack("v")) # flags
57
+ io.write([8].pack("v")) # compression: deflate
58
+ io.write([0, 0].pack("vv")) # mod time, mod date
59
+ io.write([entry.crc32].pack("V")) # crc32
60
+ io.write([entry.compressed.bytesize].pack("V")) # compressed size
61
+ io.write([entry.data.bytesize].pack("V")) # uncompressed size
62
+ io.write([name_bytes.bytesize].pack("v")) # name length
63
+ io.write([0].pack("v")) # extra length
64
+ io.write(name_bytes)
65
+ end
66
+
67
+ def write_cd_header(io, entry)
68
+ name_bytes = entry.name.b
69
+ io.write([0x02014b50].pack("V")) # signature
70
+ io.write([20].pack("v")) # version made by
71
+ io.write([20].pack("v")) # version needed
72
+ io.write([0].pack("v")) # flags
73
+ io.write([8].pack("v")) # compression: deflate
74
+ io.write([0, 0].pack("vv")) # mod time, mod date
75
+ io.write([entry.crc32].pack("V")) # crc32
76
+ io.write([entry.compressed.bytesize].pack("V")) # compressed size
77
+ io.write([entry.data.bytesize].pack("V")) # uncompressed size
78
+ io.write([name_bytes.bytesize].pack("v")) # name length
79
+ io.write([0].pack("v")) # extra length
80
+ io.write([0].pack("v")) # comment length
81
+ io.write([0].pack("v")) # disk number
82
+ io.write([0].pack("v")) # internal attrs
83
+ io.write([0].pack("V")) # external attrs
84
+ io.write([entry.offset].pack("V")) # local header offset
85
+ io.write(name_bytes)
86
+ end
87
+
88
+ def write_eocd(io, count, cd_size, cd_offset)
89
+ io.write([0x06054b50].pack("V")) # signature
90
+ io.write([0].pack("v")) # disk number
91
+ io.write([0].pack("v")) # cd disk number
92
+ io.write([count].pack("v")) # entries on this disk
93
+ io.write([count].pack("v")) # total entries
94
+ io.write([cd_size].pack("V")) # cd size
95
+ io.write([cd_offset].pack("V")) # cd offset
96
+ io.write([0].pack("v")) # comment length
97
+ end
98
+ end
99
+
100
+ # Reads entries from a ZIP archive.
101
+ class Reader
102
+ ParsedEntry = Struct.new(:name, :data)
103
+
104
+ def self.read(data)
105
+ data = data.b
106
+ entries = {}
107
+
108
+ # Find end of central directory record
109
+ eocd_pos = data.rindex([0x06054b50].pack("V"))
110
+ raise "Invalid ZIP: no EOCD record" unless eocd_pos
111
+
112
+ cd_size = data[eocd_pos + 12, 4].unpack1("V")
113
+ cd_offset = data[eocd_pos + 16, 4].unpack1("V")
114
+ entry_count = data[eocd_pos + 10, 2].unpack1("v")
115
+
116
+ # Parse central directory to get entry metadata
117
+ pos = cd_offset
118
+ entry_count.times do
119
+ sig = data[pos, 4].unpack1("V")
120
+ raise "Invalid CD entry signature" unless sig == 0x02014b50
121
+
122
+ compression = data[pos + 10, 2].unpack1("v")
123
+ crc32 = data[pos + 16, 4].unpack1("V")
124
+ comp_size = data[pos + 20, 4].unpack1("V")
125
+ uncomp_size = data[pos + 24, 4].unpack1("V")
126
+ name_len = data[pos + 28, 2].unpack1("v")
127
+ extra_len = data[pos + 30, 2].unpack1("v")
128
+ comment_len = data[pos + 32, 2].unpack1("v")
129
+ local_offset = data[pos + 42, 4].unpack1("V")
130
+ name = data[pos + 46, name_len].force_encoding("UTF-8")
131
+
132
+ # Read from local file header to get actual data
133
+ local_name_len = data[local_offset + 26, 2].unpack1("v")
134
+ local_extra_len = data[local_offset + 28, 2].unpack1("v")
135
+ data_start = local_offset + 30 + local_name_len + local_extra_len
136
+ raw = data[data_start, comp_size]
137
+
138
+ content = case compression
139
+ when 0 then raw
140
+ when 8 then Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(raw)
141
+ else raise "Unsupported compression method: #{compression}"
142
+ end
143
+
144
+ entries[name] = content.force_encoding("UTF-8")
145
+
146
+ pos += 46 + name_len + extra_len + comment_len
147
+ end
148
+
149
+ entries
150
+ end
151
+ end
152
+ end
153
+ end
data/lib/opa.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "opa/version"
4
4
  require_relative "opa/manifest"
5
+ require_relative "opa/zip_io"
5
6
  require_relative "opa/archive"
6
7
  require_relative "opa/signer"
7
8
  require_relative "opa/verifier"
metadata CHANGED
@@ -1,34 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opa-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
- - OPA Ruby Contributors
7
+ - Steve Hannah
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
11
  date: 2026-03-08 00:00:00.000000000 Z
11
12
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: rubyzip
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '2.0'
19
- - - "<"
20
- - !ruby/object:Gem::Version
21
- version: '3.0'
22
- type: :runtime
23
- prerelease: false
24
- version_requirements: !ruby/object:Gem::Requirement
25
- requirements:
26
- - - ">="
27
- - !ruby/object:Gem::Version
28
- version: '2.0'
29
- - - "<"
30
- - !ruby/object:Gem::Version
31
- version: '3.0'
32
13
  - !ruby/object:Gem::Dependency
33
14
  name: rspec
34
15
  requirement: !ruby/object:Gem::Requirement
@@ -59,11 +40,15 @@ dependencies:
59
40
  version: '13.0'
60
41
  description: Provides tools for building, signing, and verifying OPA archives — portable
61
42
  ZIP-based packages for AI agent prompts, session history, and data assets.
43
+ email:
44
+ - steve@weblite.ca
62
45
  executables:
63
46
  - opa
64
47
  extensions: []
65
48
  extra_rdoc_files: []
66
49
  files:
50
+ - LICENSE
51
+ - README.md
67
52
  - bin/opa
68
53
  - lib/opa.rb
69
54
  - lib/opa/archive.rb
@@ -72,10 +57,12 @@ files:
72
57
  - lib/opa/signer.rb
73
58
  - lib/opa/verifier.rb
74
59
  - lib/opa/version.rb
60
+ - lib/opa/zip_io.rb
75
61
  homepage: https://github.com/shannah/opa-ruby
76
62
  licenses:
77
63
  - MIT
78
64
  metadata: {}
65
+ post_install_message:
79
66
  rdoc_options: []
80
67
  require_paths:
81
68
  - lib
@@ -90,7 +77,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
77
  - !ruby/object:Gem::Version
91
78
  version: '0'
92
79
  requirements: []
93
- rubygems_version: 3.6.3
80
+ rubygems_version: 3.5.22
81
+ signing_key:
94
82
  specification_version: 4
95
83
  summary: A Ruby library for generating Open Prompt Archive (OPA) files
96
84
  test_files: []