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.
- checksums.yaml +7 -0
- data/README.md +319 -0
- data/lib/shojiku/artifact.rb +78 -0
- data/lib/shojiku/client.rb +213 -0
- data/lib/shojiku/config.rb +91 -0
- data/lib/shojiku/diagnostic.rb +43 -0
- data/lib/shojiku/engine.rb +142 -0
- data/lib/shojiku/env.rb +39 -0
- data/lib/shojiku/errors.rb +96 -0
- data/lib/shojiku/failure.rb +56 -0
- data/lib/shojiku/library.rb +105 -0
- data/lib/shojiku/local_pem.rb +77 -0
- data/lib/shojiku/lockdown.rb +95 -0
- data/lib/shojiku/log.rb +56 -0
- data/lib/shojiku/outcome.rb +75 -0
- data/lib/shojiku/request.rb +59 -0
- data/lib/shojiku/result.rb +75 -0
- data/lib/shojiku/settings.rb +74 -0
- data/lib/shojiku/sources.rb +14 -0
- data/lib/shojiku/template_root.rb +162 -0
- data/lib/shojiku/verification_report.rb +62 -0
- data/lib/shojiku/version.rb +7 -0
- data/lib/shojiku.rb +53 -0
- metadata +90 -0
|
@@ -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
|
data/lib/shojiku/env.rb
ADDED
|
@@ -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
|