shojiku 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.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Process-wide configuration: the {Shojiku::Config} object and the
4
+ # module-level entry points that reach it.
5
+ module Shojiku
6
+ # Process-wide defaults for every {Client} built after it is set.
7
+ #
8
+ # The ecosystem idiom (a `configure` block in an initializer) OVER the
9
+ # frozen constructor, never a third precedence layer: what `configure` sets
10
+ # stands exactly where an explicit constructor argument stands against the
11
+ # environment. So the order is
12
+ #
13
+ # explicit argument > `Shojiku.configure` > `SHOJIKU_*`
14
+ #
15
+ # for the template root and the pack directories, and the deliberate
16
+ # reverse for the engine library — `SHOJIKU_LIBRARY` still wins over both,
17
+ # because where the engine lives is a deployment decision.
18
+ #
19
+ # **`strict` is the one exception, and it is the only place `configure`
20
+ # beats a call site.** Strictness is a restriction rather than a default: an
21
+ # operator who declared a lockdown must not have it lifted by application
22
+ # code passing `strict: false`. Every SDK mirrors that asymmetry.
23
+ #
24
+ # The rule the other six mirror: an ecosystem-standard configuration idiom
25
+ # (an options object, properties, a builder) feeds the same constructor and
26
+ # never adds a precedence level of its own.
27
+ class Config
28
+ # Every setting a client can take, which is also what {#merge} accepts —
29
+ # so a misspelled key is a named error rather than a silently ignored one.
30
+ ATTRIBUTES = %i[
31
+ templates font_dirs locale_dirs lang library logger strict providers env
32
+ ].freeze
33
+
34
+ attr_accessor(*ATTRIBUTES)
35
+
36
+ def initialize
37
+ @strict = false
38
+ @providers = {}
39
+ @env = true
40
+ end
41
+
42
+ # A copy with `overrides` applied — one client's resolution step.
43
+ #
44
+ # A nil override means "not given", so an explicit constructor argument
45
+ # beats a configured default and an absent one inherits it. `strict` is
46
+ # the exception documented above: it is OR-ed rather than overridden.
47
+ #
48
+ # `providers` replaces rather than merges. A client that declares its own
49
+ # registry is stating the whole set it may sign with, and quietly adding
50
+ # globally-registered keys to that set would defeat the point.
51
+ def merge(overrides)
52
+ merged = dup
53
+ overrides.each do |key, value|
54
+ raise UsageError, "unknown client setting `#{Echo.bounded(key)}`" unless
55
+ ATTRIBUTES.include?(key)
56
+
57
+ merged.public_send(:"#{key}=", value) unless value.nil?
58
+ end
59
+ merged.strict = strict || merged.strict
60
+ merged
61
+ end
62
+ end
63
+
64
+ class << self
65
+ # The process-wide defaults. Mutated through {configure}, read by every
66
+ # {Client} at construction.
67
+ def config
68
+ @config ||= Config.new
69
+ end
70
+
71
+ # ```ruby
72
+ # Shojiku.configure do |config|
73
+ # config.templates = "app/templates"
74
+ # config.lang = "ja-JP"
75
+ # end
76
+ # ```
77
+ def configure
78
+ yield(config)
79
+ config
80
+ end
81
+
82
+ # Drops every configured default.
83
+ #
84
+ # Public because a global that cannot be reset makes every test suite
85
+ # invent its own teardown — and get it wrong in a randomly-ordered run.
86
+ # Applications call it at most once, if at all.
87
+ def reset_configuration!
88
+ @config = Config.new
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shojiku
4
+ # One thing the engine noticed about a document.
5
+ #
6
+ # Passed through, never interpreted. `code` and `args` are the engine's
7
+ # frozen contract — a translating consumer renders its own message from
8
+ # them — so this class parses the wire and stops. It does not translate, it
9
+ # does not re-classify, and it never becomes an exception: a render that
10
+ # warns still succeeded, and a render that failed says why in these.
11
+ class Diagnostic
12
+ attr_reader :severity, :code, :category, :message, :path, :args, :origin
13
+
14
+ def self.parse(json)
15
+ return [] if json.nil? || json.empty?
16
+
17
+ items = JSON.parse(json)["items"]
18
+ Array(items).map { |item| new(item) }
19
+ end
20
+
21
+ def initialize(item)
22
+ @severity = item["severity"]
23
+ @code = item["code"]
24
+ @category = item["category"]
25
+ @message = item["message"]
26
+ @path = item["path"]
27
+ @args = item["args"] || {}
28
+ @origin = item["origin"]
29
+ end
30
+
31
+ def error?
32
+ @severity == "error"
33
+ end
34
+
35
+ def warning?
36
+ @severity == "warning"
37
+ end
38
+
39
+ def to_s
40
+ [@path, @message].compact.join(": ")
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module Shojiku
6
+ # Everything copied out of one result handle, before that handle is freed.
7
+ #
8
+ # A snapshot rather than a wrapper, and that is the ownership rule of this
9
+ # binding in one word: no Ruby object ever holds a pointer into engine
10
+ # memory. The accessors LEND — their pointers die with the handle — so the
11
+ # bytes are copied while the handle is alive and the handle is freed on the
12
+ # way out, on every path.
13
+ Snapshot = Data.define(:status, :success, :pdf, :json, :diagnostics, :error)
14
+
15
+ # The declared C surface, and the one place a call crosses into it.
16
+ #
17
+ # Every function is declared with explicit argument and return types.
18
+ # Fiddle's default return type is a C `int`, which truncates every pointer
19
+ # this library hands back — a segfault that looks like a memory bug and is
20
+ # really a missing declaration.
21
+ class Engine
22
+ VOIDP = Fiddle::TYPE_VOIDP
23
+ SIZE_T = Fiddle::TYPE_SIZE_T
24
+ INT = Fiddle::TYPE_INT
25
+ VOID = Fiddle::TYPE_VOID
26
+
27
+ # Unpack directives that match the C types EXACTLY, rather than Ruby's
28
+ # native-width shorthands. `l!` is a native `long`, which is 8 bytes where
29
+ # `int32_t` is 4 — and `unpack1` on a buffer shorter than its directive
30
+ # returns nil rather than raising, so every flag would silently read as
31
+ # false. `l` is int32 and these two are picked from the real `size_t`
32
+ # width, which differs from `unsigned long` on Windows.
33
+ INT32 = "l"
34
+ SIZE = Fiddle::SIZEOF_SIZE_T == 8 ? "Q" : "L"
35
+
36
+ # Only the lifecycle the SDK contract defines is bound: engine info,
37
+ # render, sign, verify. `validate` and `preview` are the authoring
38
+ # surface's, not an artifact lifecycle's — the Designer reaches them
39
+ # through the WASM bindings, and binding them here would be surface with
40
+ # no contract behind it.
41
+ def initialize(library)
42
+ @library = library
43
+ @info = library.function(:shojiku_engine_info, [VOIDP], INT)
44
+ @render = library.function(:shojiku_render, [VOIDP, SIZE_T, VOIDP], INT)
45
+ @sign = library.function(
46
+ :shojiku_sign,
47
+ [VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP], INT
48
+ )
49
+ @verify = library.function(:shojiku_verify, [VOIDP, SIZE_T, VOIDP, SIZE_T, VOIDP], INT)
50
+ declare_accessors(library)
51
+ end
52
+
53
+ def engine_info
54
+ invoke { |out| @info.call(out) }
55
+ end
56
+
57
+ def render(request)
58
+ invoke { |out| @render.call(request, request.bytesize, out) }
59
+ end
60
+
61
+ def sign(pdf:, key:, certificate:, passphrase: nil)
62
+ invoke do |out|
63
+ @sign.call(
64
+ pdf, pdf.bytesize, key, key.bytesize, certificate, certificate.bytesize,
65
+ passphrase, passphrase ? passphrase.bytesize : 0, out
66
+ )
67
+ end
68
+ end
69
+
70
+ def verify(pdf:, anchors:)
71
+ invoke { |out| @verify.call(pdf, pdf.bytesize, anchors, anchors.bytesize, out) }
72
+ end
73
+
74
+ private
75
+
76
+ def declare_accessors(library)
77
+ buffers = %i[shojiku_result_pdf shojiku_result_json shojiku_result_diagnostics_json
78
+ shojiku_result_error_json]
79
+ @buffers = buffers.to_h { |name| [name, library.function(name, [VOIDP] * 3, INT)] }
80
+ @success = library.function(:shojiku_result_success, [VOIDP, VOIDP], INT)
81
+ @free = library.function(:shojiku_result_free, [VOIDP], VOID)
82
+ end
83
+
84
+ # Runs one operation and copies its result out.
85
+ #
86
+ # The `ensure` is the ownership contract: exactly one handle crosses and
87
+ # exactly one free pairs with it, whatever happens in between.
88
+ def invoke
89
+ out = zeroed(Fiddle::SIZEOF_VOIDP)
90
+ status = yield(out)
91
+ handle = out.ptr
92
+ begin
93
+ snapshot(status, handle)
94
+ ensure
95
+ @free.call(handle)
96
+ end
97
+ end
98
+
99
+ def snapshot(status, handle)
100
+ Snapshot.new(
101
+ status: status,
102
+ success: succeeded?(handle),
103
+ pdf: buffer(handle, :shojiku_result_pdf),
104
+ json: text(handle, :shojiku_result_json),
105
+ diagnostics: text(handle, :shojiku_result_diagnostics_json),
106
+ error: text(handle, :shojiku_result_error_json)
107
+ )
108
+ end
109
+
110
+ def succeeded?(handle)
111
+ slot = zeroed(Fiddle::SIZEOF_INT)
112
+ @success.call(handle, slot)
113
+ slot[0, Fiddle::SIZEOF_INT].unpack1(INT32) == 1
114
+ end
115
+
116
+ # Copies what an accessor lent. `to_str` copies, which is the whole point:
117
+ # the pointer it copies from stops being valid the moment the handle is
118
+ # freed, a few lines later.
119
+ def buffer(handle, name)
120
+ pointer = zeroed(Fiddle::SIZEOF_VOIDP)
121
+ length = zeroed(Fiddle::SIZEOF_SIZE_T)
122
+ @buffers.fetch(name).call(handle, pointer, length)
123
+ size = length[0, Fiddle::SIZEOF_SIZE_T].unpack1(SIZE)
124
+ size.zero? ? (+"").b : pointer.ptr.to_str(size)
125
+ end
126
+
127
+ # The same, for a buffer the surface guarantees is UTF-8. The encoding is
128
+ # forced rather than inherited: a platform default would differ on
129
+ # Windows, which is a first-class target here.
130
+ def text(handle, name)
131
+ buffer(handle, name).force_encoding(Encoding::UTF_8)
132
+ end
133
+
134
+ # Out-parameters start at zero, so a slot the library never wrote reads
135
+ # as "absent" rather than as whatever was in that memory.
136
+ def zeroed(size)
137
+ slot = Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
138
+ slot[0, size] = "\x00" * size
139
+ slot
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shojiku
4
+ # The one place this gem reads the environment.
5
+ #
6
+ # A client is constructed with `env: true` (the default) or `env: false`,
7
+ # and that single flag governs EVERY `SHOJIKU_*` lookup — the template root,
8
+ # the font and locale directories, and the library path. One flag rather
9
+ # than one per variable is the reference decision the other six SDKs mirror:
10
+ # an application that wants a hermetic configuration wants all of it off,
11
+ # and a per-variable set of knobs is a shape nobody can keep consistent
12
+ # across seven languages.
13
+ # Disabled lookups behave exactly as unset variables do, so calling code
14
+ # has no second branch to get wrong.
15
+ class Env
16
+ def initialize(enabled:, source: ENV)
17
+ @enabled = enabled
18
+ @source = source
19
+ end
20
+
21
+ # The variable's value, or nil when it is unset, blank, or lookups are off.
22
+ def [](name)
23
+ return nil unless @enabled
24
+
25
+ value = @source[name]
26
+ value if value && !value.empty?
27
+ end
28
+
29
+ # A `PATH_SEPARATOR`-separated variable as a list of directories, which is
30
+ # how every other tool in this family spells "several paths in one
31
+ # variable".
32
+ def paths(name)
33
+ value = self[name]
34
+ return [] unless value
35
+
36
+ value.split(File::PATH_SEPARATOR).reject(&:empty?)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shojiku
4
+ # The base of everything this gem raises.
5
+ #
6
+ # Raising is deliberately rare here. A template that will not render, a key
7
+ # that will not sign, a signature that does not verify are OUTCOMES — they
8
+ # come back as {Result} objects you query, never as exceptions you rescue.
9
+ # What is left for exceptions is what every Ruby library reserves them for:
10
+ # programmer misuse, and an environment that cannot host the engine at all.
11
+ class Error < StandardError; end
12
+
13
+ # The caller passed something this API cannot accept — a template name that
14
+ # is not a String, a nil where a value is required, both forms of the same
15
+ # material at once, an argument past a hard cap the C library documents, or
16
+ # an entrance this client's {Lockdown} disables. Programmer misuse, so it
17
+ # raises.
18
+ #
19
+ # A BLANK template name is deliberately not in that list: an empty string
20
+ # can arrive straight from a form field, so it comes back as a refused
21
+ # request like every other bad name.
22
+ class UsageError < Error; end
23
+
24
+ # Unwrapping a {Result} that failed.
25
+ #
26
+ # `artifact!` / `report!` are the opt-in bridge to exception-style control
27
+ # flow. Calling one on a failed result is programmer misuse — the ruling is
28
+ # explicit and frozen for every Shojiku SDK, because an accessor that raises
29
+ # is the one place this API could drift back into exceptions by accident.
30
+ # The failure travels on the exception, so nothing is lost by taking the
31
+ # short road.
32
+ class UnwrapError < Error
33
+ attr_reader :failure
34
+
35
+ def initialize(failure)
36
+ @failure = failure
37
+ super(failure.to_s)
38
+ end
39
+ end
40
+
41
+ # The engine library could not be found or loaded.
42
+ #
43
+ # The message names the install channels, because the fix is always an
44
+ # installation step and a bare loader error names none of them. Nothing in
45
+ # this gem downloads the library: an SDK that fetches an executable is a
46
+ # supply-chain surface this product does not take on.
47
+ class LibraryNotFound < Error; end
48
+
49
+ # The library loaded but implements a different ABI revision than this gem
50
+ # was written against. Loading anyway would mean calling symbols whose
51
+ # meaning has changed.
52
+ class AbiMismatch < Error; end
53
+
54
+ # Key, certificate or trust-anchor bytes that could not be read.
55
+ #
56
+ # Raised internally and caught by {Client}, which turns it into a failed
57
+ # {Result}: an unreadable key is an outcome of the operation, not a bug in
58
+ # the calling program. It carries the machine-readable `kind` the failure
59
+ # trace reports.
60
+ class MaterialUnreadable < Error
61
+ attr_reader :kind
62
+
63
+ def initialize(kind, message)
64
+ @kind = kind
65
+ super(message)
66
+ end
67
+ end
68
+
69
+ # Echoing caller-supplied text back in a message or a log line.
70
+ #
71
+ # Template names and provider names reach exception reporters and log files,
72
+ # so they are stripped of control characters and bounded before they are
73
+ # quoted — the same discipline the engine applies to the values it echoes.
74
+ # One place for it, because every path that echoes owes the same thing.
75
+ module Echo
76
+ LIMIT = 80
77
+
78
+ def self.bounded(text)
79
+ text.to_s.delete("\x00-\x1f\x7f")[0, LIMIT]
80
+ end
81
+ end
82
+
83
+ # Reading the byte inputs signing and verification take.
84
+ #
85
+ # One place, because both paths owe the same thing: binary mode (PEM is
86
+ # bytes, and a transcode would corrupt a DER-bearing file), and an
87
+ # unreadable file surfacing as {MaterialUnreadable} rather than as a raw
88
+ # `Errno` nobody upstream is catching.
89
+ module Material
90
+ def self.read(path, kind)
91
+ File.binread(path)
92
+ rescue SystemCallError => e
93
+ raise MaterialUnreadable.new(kind, e.message)
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shojiku
4
+ # Why a lifecycle operation did not produce what was asked for.
5
+ #
6
+ # A VALUE, not an exception. The shape takes effect-ts's `Cause` as its
7
+ # conceptual reference: which step failed, what class of thing went wrong,
8
+ # and — when one failure happened because of another — the chain underneath
9
+ # it, all inspectable rather than unwound. No effect framework is involved;
10
+ # only the idea that a failure is data.
11
+ class Failure
12
+ # The lifecycle step, as a symbol: `:generate`, `:sign` or `:verify`.
13
+ #
14
+ # Always one of those three — the SDK's own vocabulary, from
15
+ # `docs/agents/sdk.md`. The engine's error object carries a step of its
16
+ # own naming an INTERNAL stage (`render`, `validate`), and passing that
17
+ # through would make the trace's step mean different things depending on
18
+ # which layer refused. What the engine said specifically is in {#kind}.
19
+ attr_reader :step
20
+
21
+ # A stable machine-readable class. Engine-side kinds come straight off the
22
+ # wire; host-side ones are this gem's own (`template_name`, `io`, …).
23
+ attr_reader :kind
24
+
25
+ attr_reader :message, :diagnostics, :cause
26
+
27
+ def self.from_error_json(json, step:, diagnostics: [], cause: nil)
28
+ parsed = json.nil? || json.empty? ? {} : JSON.parse(json)
29
+ new(
30
+ step: step,
31
+ kind: parsed.fetch("kind", "unknown"),
32
+ message: parsed.fetch("message", ""),
33
+ diagnostics: diagnostics,
34
+ cause: cause
35
+ )
36
+ end
37
+
38
+ def initialize(step:, kind:, message:, diagnostics: [], cause: nil)
39
+ @step = step.to_sym
40
+ @kind = kind
41
+ @message = message
42
+ @diagnostics = diagnostics
43
+ @cause = cause
44
+ end
45
+
46
+ # This failure and everything under it, outermost first. What you log when
47
+ # you want the whole story rather than only its headline.
48
+ def causes
49
+ [self] + (@cause ? @cause.causes : [])
50
+ end
51
+
52
+ def to_s
53
+ "#{@step}/#{@kind}: #{@message}"
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module Shojiku
6
+ # Finding and opening the engine's shared library.
7
+ #
8
+ # Resolution order, and the deliberate asymmetry with the template root:
9
+ # `SHOJIKU_LIBRARY` beats explicit configuration, which beats the copy
10
+ # shipped inside the platform gem. That is the reverse of how the template
11
+ # root resolves, and on purpose — WHERE THE ENGINE LIVES is an
12
+ # operator/deployment decision that has to be able to win over application
13
+ # code, exactly as `SHOJIKU_BIN` does for the subprocess SDKs. WHICH
14
+ # TEMPLATES an application renders is the application's own decision, so
15
+ # there the explicit value wins.
16
+ #
17
+ # Nothing here downloads anything. A library that is not present is a named
18
+ # error listing the install channels.
19
+ class Library
20
+ # The ABI revision this gem is written against. It moves only when a
21
+ # symbol's meaning changes; new operations are appended without it, so a
22
+ # newer engine keeps working with this gem.
23
+ ABI_VERSION = 1
24
+
25
+ # The names a platform gem's binary can have, in the order they are
26
+ # tried. Windows is the reason there are six rather than three: cargo
27
+ # emits `shojiku_capi.dll` with no `lib` prefix, while the Unix targets
28
+ # get one. Looking only for the prefixed form would make the gem
29
+ # unloadable on the platform the .NET market runs on.
30
+ NAMES = %w[.so .dylib .dll].flat_map do |suffix|
31
+ ["libshojiku_capi#{suffix}", "shojiku_capi#{suffix}"]
32
+ end.freeze
33
+
34
+ # Where a platform gem puts the binary it ships.
35
+ PACKAGED_DIR = File.expand_path("native", __dir__)
36
+
37
+ attr_reader :path
38
+
39
+ # Opens the library, or raises {LibraryNotFound} naming how to install it.
40
+ def initialize(path: nil, env: Env.new(enabled: true), log: Log.new)
41
+ @log = log
42
+ @path, @source = discover(path, env)
43
+ raise LibraryNotFound, install_hint("no engine library was found") unless @path
44
+
45
+ @handle = open_handle(@path)
46
+ @log.event(:library_loaded, path: @path, source: @source)
47
+ check_abi
48
+ end
49
+
50
+ # A declared foreign function. Types are always explicit: Fiddle's
51
+ # defaults would return a C `int` and truncate every pointer this surface
52
+ # hands back.
53
+ def function(name, args, returns)
54
+ Fiddle::Function.new(@handle[name.to_s], args, returns)
55
+ rescue Fiddle::DLError => e
56
+ raise LibraryNotFound, "#{@path} exports no `#{name}` (#{e.message})"
57
+ end
58
+
59
+ private
60
+
61
+ # The resolution order, and which position won — the second half is worth
62
+ # reporting, because "which library did this process actually load, and
63
+ # why that one" is the question a deployment asks at 3am.
64
+ def discover(path, env)
65
+ return [env["SHOJIKU_LIBRARY"], :environment] if env["SHOJIKU_LIBRARY"]
66
+ return [path, :configuration] if path
67
+
68
+ [packaged, :packaged]
69
+ end
70
+
71
+ def packaged
72
+ NAMES.map { |name| File.join(PACKAGED_DIR, name) }
73
+ .find { |candidate| File.file?(candidate) }
74
+ end
75
+
76
+ def open_handle(path)
77
+ Fiddle::Handle.new(path)
78
+ rescue Fiddle::DLError => e
79
+ raise LibraryNotFound, install_hint("#{path} could not be loaded (#{e.message})")
80
+ end
81
+
82
+ # Asked once, before anything else is called — the header's own advice,
83
+ # and the only way a binding learns that a symbol it is about to call
84
+ # means something different now.
85
+ def check_abi
86
+ found = function(:shojiku_abi_version, [], Fiddle::TYPE_INT).call
87
+ @log.event(:abi_checked, found: found, expected: ABI_VERSION)
88
+ return if found == ABI_VERSION
89
+
90
+ raise AbiMismatch,
91
+ "#{@path} implements ABI revision #{found}; this gem speaks #{ABI_VERSION}"
92
+ end
93
+
94
+ def install_hint(reason)
95
+ <<~MESSAGE.strip
96
+ #{reason}.
97
+
98
+ This gem never downloads the engine. Install it one of these ways:
99
+ * install the platform gem for your system, which ships the binary
100
+ * point SHOJIKU_LIBRARY at a libshojiku_capi library you built
101
+ * pass Shojiku::Client.new(library: "/path/to/libshojiku_capi.so")
102
+ MESSAGE
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shojiku
4
+ # A signing provider backed by a PEM key and certificate.
5
+ #
6
+ # The only provider this release has. KMS and HSM providers are a recorded
7
+ # deferral, which is why this is a named class rather than a pair of
8
+ # arguments on `sign` — a second provider then adds a class, not a signature
9
+ # change in seven languages.
10
+ #
11
+ # The material comes either from paths (`key:` / `cert:`) or from bytes
12
+ # already in memory (`key_pem:` / `cert_pem:`), so a key fetched from a
13
+ # secret manager never has to be written to disk first. Which one you passed
14
+ # is explicit rather than sniffed: guessing whether a string is a path or a
15
+ # PEM body is exactly the kind of cleverness that reads the wrong file.
16
+ #
17
+ # Nothing here logs key material, and the engine builds its refusals from
18
+ # fixed strings, so a rejection cannot echo it back either.
19
+ class LocalPem
20
+ FORMS = "`%<what>s:` (a path) or `%<what>s_pem:` (bytes)"
21
+
22
+ attr_reader :passphrase
23
+
24
+ def initialize(key: nil, cert: nil, key_pem: nil, cert_pem: nil, passphrase: nil)
25
+ @key_path = key
26
+ @cert_path = cert
27
+ @key_pem = key_pem
28
+ @cert_pem = cert_pem
29
+ @passphrase = passphrase
30
+ one_source!(key, key_pem, "key")
31
+ one_source!(cert, cert_pem, "cert")
32
+ end
33
+
34
+ # Redacted, deliberately.
35
+ #
36
+ # The default `#inspect` prints every instance variable, which here is the
37
+ # private key and the passphrase — into a console, a `binding.irb`, an
38
+ # exception reporter's local-variable dump, or any log line that
39
+ # interpolates the provider. None of that is worth showing, so nothing is
40
+ # shown but the class and which FORM each half came from. Registering the
41
+ # provider once (see {Lockdown}) shrinks this surface further: material
42
+ # loads into one object instead of being rebuilt per request.
43
+ def inspect
44
+ "#<#{self.class.name} key=#{form(@key_path)} cert=#{form(@cert_path)} " \
45
+ "passphrase=#{@passphrase ? "[redacted]" : "none"}>"
46
+ end
47
+
48
+ def key
49
+ @key ||= @key_pem || Material.read(@key_path, "key_unreadable")
50
+ end
51
+
52
+ def certificate
53
+ @certificate ||= @cert_pem || Material.read(@cert_path, "certificate_unreadable")
54
+ end
55
+
56
+ private
57
+
58
+ # The path, or a note that the bytes came from memory. A configured file
59
+ # path is not secret and is the one thing worth seeing when a provider
60
+ # loaded the wrong material; the bytes themselves are never printed.
61
+ def form(path)
62
+ path ? path.to_s : "[pem bytes]"
63
+ end
64
+
65
+ # Explicit, never sniffed — in BOTH directions. Guessing whether a string
66
+ # is a path or a PEM body is how the wrong file gets read; accepting both
67
+ # forms and silently preferring one ignores the argument the caller meant,
68
+ # which is the same mistake one layer quieter.
69
+ def one_source!(path, pem, what)
70
+ forms = format(FORMS, what: what)
71
+ raise UsageError, "LocalPem takes either #{forms}, not both" if path && pem
72
+ return if path || pem
73
+
74
+ raise UsageError, "LocalPem needs either #{forms}"
75
+ end
76
+ end
77
+ end