gmeow-gts 1.0.0.rc.1

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.
Files changed (4) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +125 -0
  3. data/lib/gmeow/gts.rb +323 -0
  4. metadata +64 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f2e169a98f542d68a7bf37cae39f22769ee76e54c5950fcfe93e5507d3bc5bb5
4
+ data.tar.gz: 6691d4c9a30319c16ce9c6430b06fef67dc9b7af81a813cbff909c1df4b5b180
5
+ SHA512:
6
+ metadata.gz: dc1c389118859615b4f72f60f6da0def15b3a31e994ed3f94238eda9508105c93d68e7daab30acc7f1602a11d8194dcbb875dbf23eb646477f2b8c8fc3cfde20
7
+ data.tar.gz: 5a647fabae6d3a5dac905454d241634d6c40296b318708ddc9c14ab664c8d50b491d8b9645d24242049a39e33af563cd33218842dbccf044458f100c55095602
data/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # GTS Ruby C ABI Wrapper
2
+
3
+ <!-- SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. <paudley@blackcatinformatics.ca> -->
4
+ <!-- SPDX-License-Identifier: MIT OR Apache-2.0 -->
5
+
6
+ `gmeow-gts` is a thin Ruby FFI wrapper over the Rust-backed `libgts` C ABI. It
7
+ does not parse, fold, write, or verify GTS archives in Ruby; all GTS semantics
8
+ come from the checked-in C ABI in `rust/capi/include/gts.h`.
9
+
10
+ ## Requirements
11
+
12
+ - Ruby 3.1 or newer.
13
+ - The `ffi` gem.
14
+ - A built `libgts` shared library from `rust/capi`.
15
+
16
+ Build the shared library from the repository root:
17
+
18
+ ```sh
19
+ cargo build --manifest-path rust/capi/Cargo.toml
20
+ ```
21
+
22
+ Point the wrapper at the library with `GTS_LIBGTS`:
23
+
24
+ ```sh
25
+ export GTS_LIBGTS="$PWD/rust/capi/target/debug/libgts.so"
26
+ ruby -I ruby/lib ruby/tests/smoke.rb \
27
+ vectors/01-minimal.gts vectors/04-damaged-frame.gts vectors/28-empty-file.gts
28
+ ```
29
+
30
+ On macOS use `libgts.dylib`; on Windows use `gts.dll`. If `GTS_LIBGTS` is not
31
+ set, the wrapper asks the platform dynamic loader for the default library name.
32
+
33
+ ## RubyGems Installation
34
+
35
+ The published `gmeow-gts` gem is source-only. It ships the Ruby FFI wrapper and
36
+ declares the `ffi` dependency, but it does not vendor `libgts`.
37
+
38
+ ```sh
39
+ gem install gmeow-gts
40
+ export GTS_LIBGTS=/path/to/libgts.so
41
+ ruby -rgmeow/gts -e 'puts Gmeow::Gts.load.version'
42
+ ```
43
+
44
+ On macOS set `GTS_LIBGTS` to the `.dylib`; on Windows set it to `gts.dll`.
45
+
46
+ ## API Shape
47
+
48
+ ```ruby
49
+ require "gmeow/gts"
50
+
51
+ gts = Gmeow::Gts.load
52
+ input = File.binread("vectors/01-minimal.gts")
53
+
54
+ metadata = gts.build_metadata_json
55
+ capabilities = gts.capabilities_json
56
+ folded = gts.read_json(input)
57
+ verified = gts.verify_json(input)
58
+ nquads = gts.to_nquads(input)
59
+ round_trip = gts.from_nquads(nquads)
60
+ ```
61
+
62
+ Files-profile helpers use Ruby strings for binary GTS payloads and ordinary path
63
+ strings for directories:
64
+
65
+ ```ruby
66
+ packed = gts.files_pack(["/path/to/tree"])
67
+ diff = gts.files_diff_json(packed, "/path/to/tree")
68
+ report = gts.files_unpack(packed, "/tmp/unpacked")
69
+ ```
70
+
71
+ These path strings are forwarded to `libgts` as NUL-terminated UTF-8 C strings.
72
+ On Windows this covers only paths representable by that contract, not every
73
+ native wide-character filesystem path. Future wide-character C ABI entry points
74
+ would be additive symbols under the compatibility policy.
75
+
76
+ ## Ownership And Errors
77
+
78
+ The wrapper copies every returned `gts_buffer` into a Ruby string and then calls
79
+ `gts_buffer_free`. If a C ABI call returns an error handle, the wrapper copies
80
+ the stable error code and message, releases the handle with `gts_error_free`,
81
+ and raises `Gmeow::Gts::Error`.
82
+
83
+ Callers never receive raw FFI pointers, buffer capacities, or Rust-owned memory.
84
+ Ruby strings are binary-safe and are used for both GTS byte streams and UTF-8
85
+ JSON/N-Quads text.
86
+
87
+ Raised errors expose:
88
+
89
+ - `operation`: C ABI operation name.
90
+ - `status`: integer C ABI status code.
91
+ - `status_name`: symbolic C ABI status name.
92
+ - `code`: stable C ABI error code.
93
+ - `detail`: human-readable diagnostic detail.
94
+
95
+ ```ruby
96
+ begin
97
+ gts.from_nquads("<https://example/s> <https://example/p> .\n")
98
+ rescue Gmeow::Gts::Error => error
99
+ warn "#{error.status_name} #{error.code}: #{error.detail}"
100
+ end
101
+ ```
102
+
103
+ ## Threading And ABI Stability
104
+
105
+ `libgts` operations are reentrant. Each call owns its output buffer and error
106
+ handle independently, and the Ruby wrapper frees them before returning or
107
+ throwing. Do not share raw FFI values between Ruby runtimes.
108
+
109
+ The wrapper targets `GTS_ABI_VERSION` 1. Check `gts.abi_version` and
110
+ `gts.capabilities_json` when loading a system-provided `libgts`.
111
+
112
+ ## Validation
113
+
114
+ Run the smoke test from the repository root:
115
+
116
+ ```sh
117
+ bash ruby/scripts/smoke.sh
118
+ ```
119
+
120
+ The script builds `libgts`, validates the gemspec, and exercises ABI metadata,
121
+ capabilities, read/fold, verify, N-Quads export/import, structured errors, and
122
+ files-profile pack/diff/unpack. It runs the smoke once from the checkout and
123
+ once through an installed local gem with the checkout removed from Ruby's load
124
+ path. If local Ruby with the `ffi` gem is missing, it uses the pinned fallback
125
+ image defined in `ruby/Dockerfile`.
data/lib/gmeow/gts.rb ADDED
@@ -0,0 +1,323 @@
1
+ # SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. <paudley@blackcatinformatics.ca>
2
+ # SPDX-License-Identifier: MIT OR Apache-2.0
3
+
4
+ require "ffi"
5
+ require "rbconfig"
6
+ require "thread"
7
+
8
+ module Gmeow
9
+ module Gts
10
+ VERSION = "1.0.0.rc.1"
11
+ ABI_VERSION = 1
12
+ UINT32_MAX = 0xffff_ffff
13
+
14
+ module Status
15
+ OK = 0
16
+ INVALID_ARGUMENT = 1
17
+ IO = 2
18
+ PARSE = 3
19
+ DIAGNOSTIC = 4
20
+ INTERNAL = 5
21
+ PANIC = 6
22
+ end
23
+
24
+ module UnpackFlags
25
+ NONE = 0
26
+ INCLUDE_SUPPRESSED = 1 << 0
27
+ ALLOW_SYMLINKS = 1 << 1
28
+ ALLOW_SPECIAL = 1 << 2
29
+ SAME_OWNER = 1 << 3
30
+ PRESERVE_SETID = 1 << 4
31
+ end
32
+
33
+ STATUS_NAMES = {
34
+ Status::OK => "OK",
35
+ Status::INVALID_ARGUMENT => "INVALID_ARGUMENT",
36
+ Status::IO => "IO",
37
+ Status::PARSE => "PARSE",
38
+ Status::DIAGNOSTIC => "DIAGNOSTIC",
39
+ Status::INTERNAL => "INTERNAL",
40
+ Status::PANIC => "PANIC"
41
+ }.freeze
42
+
43
+ class Error < StandardError
44
+ attr_reader :operation, :status, :status_name, :code, :detail
45
+
46
+ def initialize(operation:, status:, code:, detail:)
47
+ @operation = operation
48
+ @status = status
49
+ @status_name = Gts.status_name(status)
50
+ @code = code
51
+ @detail = detail
52
+ super(build_message)
53
+ end
54
+
55
+ private
56
+
57
+ def build_message
58
+ message = "#{operation} failed with #{status_name}"
59
+ message = "#{message} (#{code})" unless code.empty?
60
+ message = "#{message}: #{detail}" unless detail.empty?
61
+ message
62
+ end
63
+ end
64
+
65
+ class Buffer < FFI::Struct
66
+ layout :data, :pointer,
67
+ :len, :size_t,
68
+ :capacity, :size_t
69
+ end
70
+
71
+ module Native
72
+ @modules = {}
73
+ @mutex = Mutex.new
74
+
75
+ def self.bind(library)
76
+ key = library.to_s
77
+ @mutex.synchronize do
78
+ @modules[key] ||= build(key)
79
+ end
80
+ end
81
+
82
+ def self.build(library)
83
+ Module.new do
84
+ extend FFI::Library
85
+
86
+ ffi_lib library
87
+
88
+ attach_function :gts_abi_version, [], :uint32
89
+ attach_function :gts_version, [], :pointer
90
+
91
+ attach_function :gts_buffer_free, [:pointer], :void
92
+ attach_function :gts_error_free, [:pointer], :void
93
+ attach_function :gts_error_code, [:pointer], :pointer
94
+ attach_function :gts_error_message, [:pointer], :pointer
95
+
96
+ attach_function :gts_build_metadata_json, [:pointer, :pointer], :int
97
+ attach_function :gts_capabilities_json, [:pointer, :pointer], :int
98
+ attach_function :gts_read_json, [:pointer, :size_t, :pointer, :pointer], :int
99
+ attach_function :gts_verify_json, [:pointer, :size_t, :pointer, :pointer], :int
100
+ attach_function :gts_to_nquads, [:pointer, :size_t, :pointer, :pointer], :int
101
+ attach_function :gts_from_nquads, [:pointer, :size_t, :pointer, :pointer], :int
102
+ attach_function :gts_files_pack, [:pointer, :size_t, :pointer, :pointer], :int
103
+ attach_function :gts_files_unpack,
104
+ [:pointer, :size_t, :pointer, :uint32, :pointer, :pointer],
105
+ :int
106
+ attach_function :gts_files_diff_json,
107
+ [:pointer, :size_t, :pointer, :pointer, :pointer],
108
+ :int
109
+ end
110
+ end
111
+
112
+ private_class_method :build
113
+ end
114
+
115
+ class Library
116
+ def initialize(library = nil)
117
+ @native = Native.bind(library || Gts.default_library)
118
+ end
119
+
120
+ def abi_version
121
+ @native.gts_abi_version
122
+ end
123
+
124
+ def version
125
+ copy_c_string(@native.gts_version)
126
+ end
127
+
128
+ def build_metadata_json
129
+ call_buffer("gts_build_metadata_json") do |out, error|
130
+ @native.gts_build_metadata_json(out, error)
131
+ end
132
+ end
133
+
134
+ def capabilities_json
135
+ call_buffer("gts_capabilities_json") do |out, error|
136
+ @native.gts_capabilities_json(out, error)
137
+ end
138
+ end
139
+
140
+ def read_json(data)
141
+ with_bytes(data, "data") do |pointer, length|
142
+ call_buffer("gts_read_json") do |out, error|
143
+ @native.gts_read_json(pointer, length, out, error)
144
+ end
145
+ end
146
+ end
147
+
148
+ def verify_json(data)
149
+ with_bytes(data, "data") do |pointer, length|
150
+ call_buffer("gts_verify_json") do |out, error|
151
+ @native.gts_verify_json(pointer, length, out, error)
152
+ end
153
+ end
154
+ end
155
+
156
+ def to_nquads(data)
157
+ with_bytes(data, "data") do |pointer, length|
158
+ call_buffer("gts_to_nquads") do |out, error|
159
+ @native.gts_to_nquads(pointer, length, out, error)
160
+ end
161
+ end
162
+ end
163
+
164
+ def from_nquads(text)
165
+ with_bytes(text, "text") do |pointer, length|
166
+ call_buffer("gts_from_nquads") do |out, error|
167
+ @native.gts_from_nquads(pointer, length, out, error)
168
+ end
169
+ end
170
+ end
171
+
172
+ def files_pack(paths)
173
+ path_pointers, keepalive = native_path_list(paths)
174
+ call_buffer("gts_files_pack") do |out, error|
175
+ @native.gts_files_pack(path_pointers, keepalive.length, out, error)
176
+ end
177
+ end
178
+
179
+ def files_unpack(data, destination, flags = UnpackFlags::NONE)
180
+ destination_pointer = native_string(destination, "destination")
181
+ unpack_flags = checked_uint32(flags, "flags")
182
+
183
+ with_bytes(data, "data") do |pointer, length|
184
+ call_buffer("gts_files_unpack") do |out, error|
185
+ @native.gts_files_unpack(pointer, length, destination_pointer, unpack_flags, out, error)
186
+ end
187
+ end
188
+ end
189
+
190
+ def files_diff_json(data, directory)
191
+ directory_pointer = native_string(directory, "directory")
192
+
193
+ with_bytes(data, "data") do |pointer, length|
194
+ call_buffer("gts_files_diff_json") do |out, error|
195
+ @native.gts_files_diff_json(pointer, length, directory_pointer, out, error)
196
+ end
197
+ end
198
+ end
199
+
200
+ private
201
+
202
+ def call_buffer(operation)
203
+ out = new_buffer
204
+ error = FFI::MemoryPointer.new(:pointer)
205
+ error.write_pointer(FFI::Pointer::NULL)
206
+
207
+ begin
208
+ status = yield(out.to_ptr, error)
209
+ err_ptr = error.read_pointer
210
+ if status != Status::OK
211
+ raise build_error(operation, status, err_ptr)
212
+ end
213
+ unless err_ptr.null?
214
+ raise build_error(operation, Status::INTERNAL, err_ptr)
215
+ end
216
+ copy_buffer(out)
217
+ ensure
218
+ @native.gts_buffer_free(out.to_ptr)
219
+ end
220
+ end
221
+
222
+ def new_buffer
223
+ buffer = Buffer.new
224
+ buffer[:data] = FFI::Pointer::NULL
225
+ buffer[:len] = 0
226
+ buffer[:capacity] = 0
227
+ buffer
228
+ end
229
+
230
+ def build_error(operation, status, error)
231
+ code = ""
232
+ detail = ""
233
+
234
+ unless error.null?
235
+ begin
236
+ code = copy_c_string(@native.gts_error_code(error))
237
+ detail = copy_c_string(@native.gts_error_message(error))
238
+ ensure
239
+ @native.gts_error_free(error)
240
+ end
241
+ end
242
+
243
+ Error.new(operation: operation, status: status, code: code, detail: detail)
244
+ end
245
+
246
+ def copy_buffer(buffer)
247
+ length = buffer[:len]
248
+ return "" if length.zero?
249
+
250
+ data = buffer[:data]
251
+ raise RuntimeError, "C ABI returned a null data pointer with non-zero length." if data.null?
252
+
253
+ data.read_string_length(length)
254
+ end
255
+
256
+ def copy_c_string(pointer)
257
+ return "" if pointer.nil? || pointer.null?
258
+
259
+ pointer.read_string
260
+ end
261
+
262
+ def with_bytes(value, name)
263
+ raise TypeError, "#{name} must be a String" unless value.is_a?(String)
264
+
265
+ length = value.bytesize
266
+ pointer = FFI::MemoryPointer.new(:uint8, [length, 1].max)
267
+ pointer.put_bytes(0, value) unless length.zero?
268
+ yield(pointer, length)
269
+ end
270
+
271
+ def native_string(value, name)
272
+ raise TypeError, "#{name} must be a String" unless value.is_a?(String)
273
+ raise ArgumentError, "#{name} must not contain NUL bytes" if value.include?("\0")
274
+
275
+ FFI::MemoryPointer.from_string(value)
276
+ end
277
+
278
+ def native_path_list(paths)
279
+ raise TypeError, "paths must be an Array" unless paths.is_a?(Array)
280
+ raise ArgumentError, "paths must not be empty" if paths.empty?
281
+
282
+ pointer_array = FFI::MemoryPointer.new(:pointer, paths.length)
283
+ keepalive = paths.each_with_index.map do |path, index|
284
+ pointer = native_string(path, "paths[#{index}]")
285
+ pointer_array.put_pointer(index * FFI.type_size(:pointer), pointer)
286
+ pointer
287
+ end
288
+
289
+ [pointer_array, keepalive]
290
+ end
291
+
292
+ def checked_uint32(value, name)
293
+ raise TypeError, "#{name} must be an Integer" unless value.is_a?(Integer)
294
+ raise RangeError, "#{name} must be an unsigned 32-bit integer" if value.negative? || value > UINT32_MAX
295
+
296
+ value
297
+ end
298
+ end
299
+
300
+ def self.default_library
301
+ from_env = ENV.fetch("GTS_LIBGTS", nil)
302
+ return from_env unless from_env.nil? || from_env.empty?
303
+
304
+ host_os = RbConfig::CONFIG.fetch("host_os", "")
305
+ case host_os
306
+ when /darwin/i
307
+ "libgts.dylib"
308
+ when /mswin|mingw|cygwin/i
309
+ "gts.dll"
310
+ else
311
+ "libgts.so"
312
+ end
313
+ end
314
+
315
+ def self.load(library = nil)
316
+ Library.new(library)
317
+ end
318
+
319
+ def self.status_name(status)
320
+ STATUS_NAMES.fetch(status, "UNKNOWN")
321
+ end
322
+ end
323
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gmeow-gts
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.rc.1
5
+ platform: ruby
6
+ authors:
7
+ - Blackcat Informatics
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ffi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.17'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.17'
26
+ description: A source-only Ruby FFI wrapper over the Rust-backed libgts C ABI. The
27
+ gem expects libgts to be provided by the host at runtime.
28
+ email:
29
+ - paudley@blackcatinformatics.ca
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - README.md
35
+ - lib/gmeow/gts.rb
36
+ homepage: https://blackcatinformatics.ca/projects/gts
37
+ licenses:
38
+ - MIT
39
+ - Apache-2.0
40
+ metadata:
41
+ allowed_push_host: https://rubygems.org
42
+ changelog_uri: https://github.com/Blackcat-Informatics/gmeow-gts/blob/main/CHANGELOG.md
43
+ homepage_uri: https://blackcatinformatics.ca/projects/gts
44
+ rubygems_mfa_required: 'true'
45
+ source_code_uri: https://github.com/Blackcat-Informatics/gmeow-gts
46
+ bug_tracker_uri: https://github.com/Blackcat-Informatics/gmeow-gts/issues
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.1'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.6.9
62
+ specification_version: 4
63
+ summary: Ruby FFI wrapper for the source-only GTS C ABI.
64
+ test_files: []