secretspec 0.13.0-aarch64-linux

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: 7e233968611ffbc225308656eb83b98091685a5b2e8216a759bc014960653bd0
4
+ data.tar.gz: cb0e0050cfaf18071a75cfd5abc82d9e96b2f0aeab6591daad568e33148a0d8c
5
+ SHA512:
6
+ metadata.gz: bd5314bf2e4406c3cd22ed20decadc7a1b4d254cd972afe78fede07d82e2c677fabd9028dbdde3a2d0673a4f02d26a7eaed5d8553493bd2143ab3ff007b41678
7
+ data.tar.gz: fcf609265010fa198079bcfa581004be7de59cf6ffedaa72db525375b7f6d8bd57cd937e3184293b4d424203d3a1b645146d3e9b001f3de5a43ec289466ca297
data/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # secretspec (Ruby SDK)
2
+
3
+ Ruby bindings for [SecretSpec](https://secretspec.dev/), a declarative secrets
4
+ manager. A thin client over the `secretspec-ffi` C ABI, statically linked into a
5
+ native C extension at build time (no runtime library to locate). Resolution
6
+ happens in the Rust core, so the SDK inherits every provider with no Ruby-side
7
+ logic.
8
+
9
+ ```ruby
10
+ require "secretspec"
11
+
12
+ resolved = Secretspec::SecretSpec.builder
13
+ .with_provider("keyring://")
14
+ .with_profile("production")
15
+ .with_reason("boot web app")
16
+ .load
17
+
18
+ puts resolved.provider, resolved.profile
19
+ db = resolved.secrets["DATABASE_URL"]
20
+ puts db.get # the value, or the file path for as_path secrets
21
+ resolved.set_as_env! # export everything into ENV
22
+ ```
23
+
24
+ A missing required secret raises `Secretspec::MissingRequiredError`; any other
25
+ failure raises `Secretspec::Error` (with a stable `#kind`).
26
+
27
+ ## Cleanup
28
+
29
+ `as_path` secrets are materialized to temp files that outlive the call. Pass a
30
+ block to `load` (which closes automatically) or call `resolved.close` when done
31
+ so the secret files do not accumulate in the temp dir.
32
+
33
+ ## Value-free report
34
+
35
+ `report` returns the inventory/preflight view: per-secret status and provenance,
36
+ never a value. Unlike `load`, it does not raise when a required secret is missing
37
+ — it appears as a `SecretReport` with status `"missing_required"`.
38
+
39
+ ```ruby
40
+ report = Secretspec::SecretSpec.builder.with_profile("production").report
41
+ report.secrets.each { |s| puts [s.name, s.status, s.required].join(" ") }
42
+ ```
43
+
44
+ ## Library discovery
45
+
46
+ The native library is found via the `SECRETSPEC_FFI_LIB` environment variable,
47
+ or a Cargo `target` directory found by searching up from the working directory.
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Builds the secretspec native extension, statically linking the secretspec-ffi
4
+ # archive (libsecretspec_ffi.a) into the extension object. A Rust staticlib does
5
+ # not carry its own native dependency closure, so the archive's transitive system
6
+ # libs (captured from `rustc --print native-static-libs`, never hardcoded) are
7
+ # appended to the link line after it.
8
+
9
+ require "mkmf"
10
+
11
+ ext_dir = __dir__
12
+ pkg_dir = File.expand_path("../..", ext_dir) # secretspec-rb
13
+ repo_root = File.expand_path("..", pkg_dir) # workspace root (dev checkout)
14
+ vendor = File.join(pkg_dir, "vendor")
15
+
16
+ # The staticlib: explicit contract, the bundled platform-gem copy, or a Cargo
17
+ # target dir (dev checkout, newest of release/debug).
18
+ def find_staticlib(vendor, repo_root)
19
+ env = ENV["SECRETSPEC_FFI_STATICLIB"]
20
+ return env if env && !env.empty? && File.exist?(env)
21
+
22
+ bundled = File.join(vendor, "libsecretspec_ffi.a")
23
+ return bundled if File.exist?(bundled)
24
+
25
+ %w[release debug]
26
+ .map { |p| File.join(repo_root, "target", p, "libsecretspec_ffi.a") }
27
+ .select { |c| File.exist?(c) }
28
+ .max_by { |c| File.mtime(c) }
29
+ end
30
+
31
+ # The archive's transitive native deps: explicit contract, the bundled manifest,
32
+ # or captured live from rustc (dev checkout).
33
+ def find_native_libs(vendor, repo_root)
34
+ env = ENV["SECRETSPEC_FFI_NATIVE_LIBS"]
35
+ return env if env && !env.empty?
36
+
37
+ manifest = File.join(vendor, "native-static-libs.txt")
38
+ return File.read(manifest).strip if File.exist?(manifest)
39
+
40
+ note = `cd #{repo_root} && cargo rustc -q -p secretspec-ffi --crate-type staticlib -- --print native-static-libs 2>&1`
41
+ note[/native-static-libs:\s*(.*)/, 1].to_s.strip
42
+ end
43
+
44
+ staticlib = find_staticlib(vendor, repo_root)
45
+ abort("secretspec: could not locate libsecretspec_ffi.a; set SECRETSPEC_FFI_STATICLIB") unless staticlib
46
+
47
+ # Header: the bundled vendor copy (platform gem) or the ffi crate's include dir.
48
+ include_dir =
49
+ if File.exist?(File.join(vendor, "secretspec.h"))
50
+ vendor
51
+ else
52
+ File.join(repo_root, "secretspec-ffi", "include")
53
+ end
54
+
55
+ $INCFLAGS << " -I#{include_dir}"
56
+ # $LOCAL_LIBS is emitted before $libs on the link line, so the archive (pulled
57
+ # for the referenced symbols) precedes the system libs it depends on.
58
+ $LOCAL_LIBS << " #{staticlib}"
59
+ $libs = "#{$libs} #{find_native_libs(vendor, repo_root)}"
60
+
61
+ create_makefile("secretspec/secretspec_ext")
@@ -0,0 +1,64 @@
1
+ /*
2
+ * Native glue for the secretspec Ruby SDK.
3
+ *
4
+ * A thin C extension that statically links the secretspec-ffi archive
5
+ * (libsecretspec_ffi.a) and exposes its three C ABI functions to Ruby as
6
+ * Secretspec::Native.c_resolve / c_abi_version. The Rust resolver is embedded in
7
+ * this extension object, so there is no separate cdylib to ship or dlopen.
8
+ */
9
+ #include <ruby.h>
10
+ #include <ruby/thread.h>
11
+ #include <stdlib.h>
12
+ #include "secretspec.h"
13
+
14
+ static void *
15
+ resolve_nogvl(void *arg)
16
+ {
17
+ return secretspec_resolve((const char *)arg);
18
+ }
19
+
20
+ /*
21
+ * Secretspec::Native.c_resolve(request_json) -> String or nil
22
+ *
23
+ * Marshals the JSON request to the Rust resolver and copies the owned response
24
+ * into a Ruby String before freeing it. Returns nil if the resolver returns NULL
25
+ * (catastrophic allocation failure); the Ruby wrapper turns that into an Error.
26
+ *
27
+ * The resolver may block on network-backed providers (1Password, LastPass,
28
+ * Vault), so it runs with the GVL released — otherwise the round-trip would
29
+ * freeze every other Ruby thread. The request bytes are copied into a C-owned
30
+ * buffer first: the Ruby string may move once the GVL is released.
31
+ */
32
+ static VALUE
33
+ native_resolve(VALUE self, VALUE request_json)
34
+ {
35
+ char *request = strdup(StringValueCStr(request_json));
36
+ if (request == NULL) {
37
+ return Qnil;
38
+ }
39
+ char *result = rb_thread_call_without_gvl(
40
+ resolve_nogvl, request, RUBY_UBF_IO, NULL);
41
+ free(request);
42
+ if (result == NULL) {
43
+ return Qnil;
44
+ }
45
+ VALUE out = rb_str_new_cstr(result);
46
+ secretspec_free(result);
47
+ return out;
48
+ }
49
+
50
+ /* Secretspec::Native.c_abi_version -> String (static, not freed). */
51
+ static VALUE
52
+ native_abi_version(VALUE self)
53
+ {
54
+ return rb_str_new_cstr(secretspec_abi_version());
55
+ }
56
+
57
+ void
58
+ Init_secretspec_ext(void)
59
+ {
60
+ VALUE mod = rb_define_module("Secretspec");
61
+ VALUE native = rb_define_module_under(mod, "Native");
62
+ rb_define_singleton_method(native, "c_resolve", native_resolve, 1);
63
+ rb_define_singleton_method(native, "c_abi_version", native_abi_version, 0);
64
+ }
data/lib/secretspec.rb ADDED
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby SDK for SecretSpec, a declarative secrets manager.
4
+ #
5
+ # A thin client over the secretspec-ffi C ABI. The Rust resolver is statically
6
+ # linked into a native extension (secretspec_ext), so the SDK inherits every
7
+ # provider with no Ruby-side logic and there is nothing to locate at runtime.
8
+ # Mirrors the Rust derive crate's vocabulary.
9
+
10
+ require "json"
11
+
12
+ # The compiled extension lives next to this file in a source/dev checkout, but in
13
+ # an installed gem RubyGems places it in a separate extensions dir already on
14
+ # $LOAD_PATH. Put this file's dir on the path so the absolute require resolves in
15
+ # both layouts.
16
+ $LOAD_PATH.unshift(__dir__) unless $LOAD_PATH.include?(__dir__)
17
+ require "secretspec/secretspec_ext"
18
+
19
+ module Secretspec
20
+ # Response wire-format version this SDK understands. Tracks secretspec-ffi's
21
+ # RESOLVE_SCHEMA_VERSION; a mismatch means the loaded library is incompatible.
22
+ RESOLVE_SCHEMA_VERSION = 1
23
+
24
+ # Wire-format version of the value-free report. Tracks secretspec's
25
+ # RESOLUTION_REPORT_SCHEMA_VERSION.
26
+ REPORT_SCHEMA_VERSION = 1
27
+
28
+ # A resolution failure (bad manifest, provider error, reason policy).
29
+ class Error < StandardError
30
+ attr_reader :kind
31
+
32
+ def initialize(kind, message)
33
+ @kind = kind
34
+ super("#{message} (kind: #{kind})")
35
+ end
36
+ end
37
+
38
+ # One or more required secrets were not found anywhere.
39
+ class MissingRequiredError < Error
40
+ attr_reader :missing
41
+
42
+ def initialize(missing)
43
+ @missing = missing
44
+ super("missing_required", "missing required secret(s): #{missing.join(', ')}")
45
+ end
46
+ end
47
+
48
+ # One resolved secret. Exactly one of +value+ / +path+ is set.
49
+ ResolvedSecret = Struct.new(:value, :path, :as_path, :source, :source_provider) do
50
+ # The usable string: the file path for as_path secrets, else the value.
51
+ def get
52
+ as_path ? path : value
53
+ end
54
+ end
55
+
56
+ # A successful resolution, mirroring the Rust Resolved wrapper.
57
+ Resolved = Struct.new(:provider, :profile, :secrets, :missing_optional) do
58
+ # Export each resolved secret into ENV by its declared name. Secrets with no
59
+ # usable value (e.g. under no_values) are skipped rather than deleted from
60
+ # ENV (assigning nil would remove the variable).
61
+ def set_as_env!
62
+ secrets.each do |name, secret|
63
+ value = secret.get
64
+ ENV[name] = value unless value.nil?
65
+ end
66
+ end
67
+
68
+ # Flat { "SECRET_NAME" => value } hash (the file path for as_path). A secret
69
+ # with no usable value (e.g. under no_values) maps to nil, matching the null
70
+ # the other SDKs emit. Feed this to a quicktype-generated deserializer (e.g.
71
+ # from_dynamic!). See `secretspec schema`.
72
+ def fields
73
+ secrets.transform_values(&:get)
74
+ end
75
+
76
+ # Remove the temp files backing any as_path secrets in this result. The
77
+ # resolver persists those files (mode 0400) so their paths stay valid after
78
+ # resolve returns; the caller owns their lifetime. Call #close (or pass a
79
+ # block to Builder#load, which closes automatically) when done so secret
80
+ # files do not accumulate in the temp dir. A file already gone is not an
81
+ # error.
82
+ def close
83
+ secrets.each_value do |secret|
84
+ next unless secret.as_path && secret.path
85
+
86
+ File.delete(secret.path) if File.exist?(secret.path)
87
+ end
88
+ nil
89
+ end
90
+ end
91
+
92
+ # Value-free resolution outcome for one declared secret: how it would resolve
93
+ # and from where, never the value itself.
94
+ SecretReport = Struct.new(:name, :status, :required, :source_provider,
95
+ :default_applied, :generated, :as_path)
96
+
97
+ # A value-free resolution snapshot. Unlike Resolved, a missing required secret
98
+ # is a "missing_required" status here, not an error, so a report describes a
99
+ # profile even when its secrets are not all available.
100
+ Report = Struct.new(:provider, :profile, :secrets)
101
+
102
+ # The narrow C ABI, statically linked into the secretspec_ext extension. The
103
+ # Native.c_resolve / c_abi_version C functions are defined in
104
+ # ext/secretspec/secretspec_ext.c; these wrappers add the Ruby-side error type.
105
+ module Native
106
+ class << self
107
+ def resolve(request_json)
108
+ result = c_resolve(request_json)
109
+ raise Error.new("ffi", "secretspec_resolve returned null") if result.nil?
110
+
111
+ result
112
+ end
113
+
114
+ def abi_version
115
+ c_abi_version
116
+ end
117
+ end
118
+ end
119
+
120
+ # Entry point mirroring the derive crate's SecretSpec::builder().
121
+ class SecretSpec
122
+ def self.builder
123
+ Builder.new
124
+ end
125
+ end
126
+
127
+ # Fluent builder for a resolution.
128
+ class Builder
129
+ def initialize
130
+ @request = {}
131
+ end
132
+
133
+ def with_path(path)
134
+ @request["path"] = path if path
135
+ self
136
+ end
137
+
138
+ def with_provider(provider)
139
+ @request["provider"] = provider if provider
140
+ self
141
+ end
142
+
143
+ def with_profile(profile)
144
+ @request["profile"] = profile if profile
145
+ self
146
+ end
147
+
148
+ def with_reason(reason)
149
+ @request["reason"] = reason if reason
150
+ self
151
+ end
152
+
153
+ # Omit secret values, returning only structure and provenance.
154
+ def with_no_values(no_values = true)
155
+ @request["no_values"] = no_values
156
+ self
157
+ end
158
+
159
+ # Resolve the secrets. Raises MissingRequiredError if a required secret is
160
+ # missing, and Error for any other failure.
161
+ #
162
+ # Without a block, returns the Resolved (the caller should #close it when
163
+ # done to clean up any as_path temp files). With a block, yields the Resolved
164
+ # and closes it afterwards, returning the block's value.
165
+ def load
166
+ response = parse_response(JSON.generate(@request), "resolve", RESOLVE_SCHEMA_VERSION)
167
+
168
+ missing = response["missing_required"] || []
169
+ raise MissingRequiredError.new(missing) unless missing.empty?
170
+
171
+ secrets = {}
172
+ (response["secrets"] || {}).each do |name, entry|
173
+ secrets[name] = ResolvedSecret.new(
174
+ entry["value"], entry["path"], entry["as_path"] || false,
175
+ entry["source"], entry["source_provider"]
176
+ )
177
+ end
178
+
179
+ resolved = Resolved.new(
180
+ response["provider"], response["profile"], secrets,
181
+ response["missing_optional"] || []
182
+ )
183
+ return resolved unless block_given?
184
+
185
+ begin
186
+ yield resolved
187
+ ensure
188
+ resolved.close
189
+ end
190
+ end
191
+
192
+ # Resolve a value-free Report (the inventory/preflight view, the same one the
193
+ # CLI exposes as `check --json`). Unlike #load, never raises
194
+ # MissingRequiredError: a missing required secret appears as a SecretReport
195
+ # with status "missing_required".
196
+ def report
197
+ request = @request.merge("mode" => "report")
198
+ response = parse_response(JSON.generate(request), "report", REPORT_SCHEMA_VERSION)
199
+
200
+ secrets = (response["secrets"] || []).map do |s|
201
+ SecretReport.new(s["name"], s["status"], s["required"],
202
+ s["source_provider"], s["default_applied"],
203
+ s["generated"], s["as_path"])
204
+ end
205
+ Report.new(response["provider"], response["profile"], secrets)
206
+ end
207
+
208
+ private
209
+
210
+ # Resolve a JSON request payload and return the validated "response" hash, or
211
+ # raise. +kind+ is "resolve" or "report"; it selects the schema version to
212
+ # enforce and labels the version-mismatch message.
213
+ def parse_response(payload, kind, expected_version)
214
+ envelope = JSON.parse(Native.resolve(payload))
215
+
216
+ unless envelope["ok"]
217
+ err = envelope["error"] || {}
218
+ raise Error.new(err["kind"] || "unknown", err["message"] || "")
219
+ end
220
+
221
+ response = envelope["response"]
222
+ raise Error.new("ffi", "secretspec_resolve reported ok with no response") if response.nil?
223
+
224
+ version = response["schema_version"]
225
+ unless version == expected_version
226
+ raise Error.new("version",
227
+ "unsupported #{kind} schema version #{version} " \
228
+ "(expected #{expected_version}); the secretspec-ffi " \
229
+ "library and this SDK are out of sync")
230
+ end
231
+
232
+ response
233
+ end
234
+ end
235
+
236
+ def self.abi_version
237
+ Native.abi_version
238
+ end
239
+ end
Binary file
@@ -0,0 +1 @@
1
+ -ldbus-1 -lgcc_s -lutil -lrt -lpthread -lm -ldl -lc
@@ -0,0 +1,51 @@
1
+ /*
2
+ * SecretSpec C ABI.
3
+ *
4
+ * A deliberately narrow, JSON-in / JSON-out boundary. The entire native surface
5
+ * is the three functions below; all richness lives in the versioned JSON
6
+ * contract so language bindings stay thin.
7
+ *
8
+ * Request JSON (all fields optional):
9
+ * { "path": ".../secretspec.toml", "provider": "keyring://",
10
+ * "profile": "production", "reason": "boot", "no_values": false }
11
+ *
12
+ * Response envelope:
13
+ * { "ok": true, "response": { ...resolve response... } }
14
+ * { "ok": false, "error": { "kind": "io", "message": "..." } }
15
+ *
16
+ * The response (when ok) carries secret values unless "no_values" was set.
17
+ * Treat returned strings as sensitive and free them promptly.
18
+ */
19
+ #ifndef SECRETSPEC_H
20
+ #define SECRETSPEC_H
21
+
22
+ #ifdef __cplusplus
23
+ extern "C" {
24
+ #endif
25
+
26
+ /*
27
+ * Resolve secrets described by `request_json` (a NUL-terminated UTF-8 JSON
28
+ * string). Returns a newly allocated, NUL-terminated JSON response envelope
29
+ * that the caller OWNS and must release with secretspec_free().
30
+ *
31
+ * Returns NULL only on catastrophic allocation failure.
32
+ */
33
+ char *secretspec_resolve(const char *request_json);
34
+
35
+ /*
36
+ * Free a string previously returned by secretspec_resolve(). NULL is ignored.
37
+ * Must not be called twice on the same pointer.
38
+ */
39
+ void secretspec_free(char *ptr);
40
+
41
+ /*
42
+ * Return the ABI version as a static NUL-terminated string. Do NOT free; the
43
+ * pointer is valid for the lifetime of the loaded library.
44
+ */
45
+ const char *secretspec_abi_version(void);
46
+
47
+ #ifdef __cplusplus
48
+ } /* extern "C" */
49
+ #endif
50
+
51
+ #endif /* SECRETSPEC_H */
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: secretspec
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.13.0
5
+ platform: aarch64-linux
6
+ authors:
7
+ - Cachix
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'Ruby bindings for SecretSpec: a native extension that statically links
14
+ the secretspec-ffi C ABI.'
15
+ email:
16
+ executables: []
17
+ extensions:
18
+ - ext/secretspec/extconf.rb
19
+ extra_rdoc_files: []
20
+ files:
21
+ - README.md
22
+ - ext/secretspec/extconf.rb
23
+ - ext/secretspec/secretspec_ext.c
24
+ - lib/secretspec.rb
25
+ - vendor/libsecretspec_ffi.a
26
+ - vendor/native-static-libs.txt
27
+ - vendor/secretspec.h
28
+ homepage: https://secretspec.dev/
29
+ licenses:
30
+ - Apache-2.0
31
+ metadata: {}
32
+ post_install_message:
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.5.22
48
+ signing_key:
49
+ specification_version: 4
50
+ summary: Declarative secrets, every environment, any provider (Ruby SDK)
51
+ test_files: []