shojiku 0.1.0-x86_64-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 +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/native/libshojiku_capi.so +0 -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 +91 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d86825fdae8fc7dccbb563e21959290f8753f59cd8f420af7bd409dde7b98500
|
|
4
|
+
data.tar.gz: b5b298be47d729631ed20fe951ddbb6029f92d4d6066c59aa38cd2faef1ed53c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e27af49e536e8d94c556f997369e36ae430e9d5fa47f8cf10e91d014001651646ce2fc2464da2f84c9426666e2681a5a9b437ba65ac283ce40ad3493f23671d4
|
|
7
|
+
data.tar.gz: 1d85cc726209f3cc6f5d0b8468db35209caa5c9cb9f57278b56e83c4949e9ee893863593446b749cf81cb38e7bdd4866d3ff043ed50eb19bc57aad6ba6e38aea
|
data/README.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Shojiku for Ruby
|
|
2
|
+
|
|
3
|
+
Ruby bindings for [Shojiku](../../README.md) — a document engine that
|
|
4
|
+
turns a YAML template plus your data into a deterministic PDF, signs it,
|
|
5
|
+
and verifies it.
|
|
6
|
+
|
|
7
|
+
> **Unreleased.** The gem is written and gated but not published yet —
|
|
8
|
+
> all seven Shojiku SDKs publish together at v0.1.0. Until then, install
|
|
9
|
+
> from a repository clone (see [Building the engine
|
|
10
|
+
> library](#building-the-engine-library) below), or use the CLI or the
|
|
11
|
+
> Docker image — see the [quickstart](../../docs/quickstart.md).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle add shojiku
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Platform gems carry a prebuilt engine library, so there is no build step
|
|
20
|
+
on the supported platforms (Linux and macOS on x86-64 and arm64, Windows
|
|
21
|
+
on x86-64). The engine is loaded through `fiddle` from the standard
|
|
22
|
+
library rather than as a native extension, which means one binary serves
|
|
23
|
+
every supported Ruby — no recompile when you upgrade the interpreter.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "shojiku"
|
|
29
|
+
|
|
30
|
+
client = Shojiku::Client.new(templates: "app/templates")
|
|
31
|
+
|
|
32
|
+
result = client.generate("receipt_ja", {
|
|
33
|
+
customer: { name: "Yamada Shoji K.K." },
|
|
34
|
+
items: [{ name: "Consulting", qty: 1, price: 120_000 }],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
if result.success?
|
|
38
|
+
result.artifact.write("receipt.pdf")
|
|
39
|
+
else
|
|
40
|
+
result.failure.causes.each { |cause| warn cause.to_s }
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`params` is a Hash, or a String you already have — JSON *or* YAML, since
|
|
45
|
+
the engine parses either and a String is passed through verbatim. There
|
|
46
|
+
is no per-format method: format dispatch is the engine's.
|
|
47
|
+
|
|
48
|
+
To render one template in several locales, derive a client:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
client.with_lang("ja-JP").generate("receipt_ja", params)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A derived client shares the loaded engine — deriving re-opens nothing —
|
|
55
|
+
and the locale it names beats the one the client was built with.
|
|
56
|
+
|
|
57
|
+
### Sources you already hold
|
|
58
|
+
|
|
59
|
+
`generate` resolves a template *name* against the configured root.
|
|
60
|
+
When the sources come from somewhere else — object storage, a database,
|
|
61
|
+
a heredoc — hand them over directly:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
result = client.generate_source(
|
|
65
|
+
template: File.read("invoice.yml"), # source text, never a path
|
|
66
|
+
definitions: definitions_yaml, # optional
|
|
67
|
+
assets_dir: "app/templates/invoice", # optional; bundled images resolve here
|
|
68
|
+
params: params
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Fetching the bytes stays your application's act: this gem opens no
|
|
73
|
+
sockets and reads no template off disk here. `template:` is **source
|
|
74
|
+
text** — a value that looks like a path is a template that fails to
|
|
75
|
+
parse, not a file this gem will open — so the containment rules that
|
|
76
|
+
guard the name entrance are not being bypassed, they are simply not
|
|
77
|
+
involved. A deployment that wants to forbid this entrance entirely
|
|
78
|
+
declares [`strict:`](#locking-down-where-signable-input-comes-from).
|
|
79
|
+
|
|
80
|
+
An archived document goes back in the same way:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
archived = client.artifact(File.binread("2026-invoices/0042.pdf"))
|
|
84
|
+
archived.verify(anchors: "ca.crt")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Signing is a separate step over the rendered document, and verification
|
|
88
|
+
is a separate step over that:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
signed = result.artifact.sign(
|
|
92
|
+
Shojiku::LocalPem.new(key: "signer.key", cert: "signer.crt")
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
checked = signed.artifact.verify(anchors: "ca.crt")
|
|
96
|
+
checked.report.valid? # every check this release performs passed
|
|
97
|
+
checked.report.not_checked # => [:revocation, :timestamp]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Read `not_checked` beside the verdict.** `valid?` means "nothing we
|
|
101
|
+
looked at was wrong", not "this document is trustworthy" — revocation
|
|
102
|
+
and timestamping are not checked in this release, and the report says so
|
|
103
|
+
on a passing verdict as well as a failing one. A document whose
|
|
104
|
+
signature does not verify comes back as a *failed* result that still
|
|
105
|
+
carries the full report.
|
|
106
|
+
|
|
107
|
+
### Results, not exceptions
|
|
108
|
+
|
|
109
|
+
Nothing raises in the normal flow. Every operation returns a result you
|
|
110
|
+
query — `success?`, the artifact or report, and the engine's diagnostics
|
|
111
|
+
either way (a render that *worked* can still have warned about an
|
|
112
|
+
overflowing box). A failure carries a trace: which step failed, a stable
|
|
113
|
+
`kind`, the message, and the cause chain underneath it.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
result.warnings.each { |d| logger.warn("#{d.code}: #{d.message}") }
|
|
117
|
+
result.failure.step # => :generate
|
|
118
|
+
result.failure.kind # => "template_not_found"
|
|
119
|
+
result.failure.causes # => [the failure, its cause, …]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Diagnostics keep the engine's stable `code` and typed `args` untouched,
|
|
123
|
+
so an application that renders its own localized messages has everything
|
|
124
|
+
it needs. This gem never translates them.
|
|
125
|
+
|
|
126
|
+
Exceptions are reserved for what Ruby reserves them for: programmer
|
|
127
|
+
misuse (`Shojiku::UsageError`) and an environment with no engine in it
|
|
128
|
+
(`Shojiku::LibraryNotFound`).
|
|
129
|
+
|
|
130
|
+
For a script that would rather have a stack trace than a branch, there
|
|
131
|
+
is an opt-in unwrap:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
client.generate("receipt_ja", params).artifact!.write("receipt.pdf")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`artifact!` / `report!` raise `Shojiku::UnwrapError` (carrying the whole
|
|
138
|
+
failure) when the result failed. The ruling behind them is deliberate
|
|
139
|
+
and shared by every Shojiku SDK: **calling unwrap on a failed result is
|
|
140
|
+
programmer misuse** — a caller who has not checked `success?` is
|
|
141
|
+
asserting the operation worked. Application code that handles failure
|
|
142
|
+
keeps using `success?`.
|
|
143
|
+
|
|
144
|
+
## Templates
|
|
145
|
+
|
|
146
|
+
Template names are **identifiers, never paths**. A name resolves against
|
|
147
|
+
the configured template root:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
app/templates/
|
|
151
|
+
receipt_ja/
|
|
152
|
+
templates.yml (required)
|
|
153
|
+
definitions.yml (optional)
|
|
154
|
+
assets/ (optional — bundled images resolve against it)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Names containing a path separator, a `..` segment, a drive letter, a UNC
|
|
158
|
+
prefix, a control character, or a reserved Windows device name (`CON`,
|
|
159
|
+
`NUL`, `COM1`, …) are refused — on **every** platform, not only the ones
|
|
160
|
+
where they would resolve, so a name that works in development works in
|
|
161
|
+
production. A name that resolves outside the root through a symlink is
|
|
162
|
+
refused after canonicalization.
|
|
163
|
+
|
|
164
|
+
Every one of those is a *failed result*: a hostile name is a fact about
|
|
165
|
+
the request, not a bug in your program. A name that is not a String at
|
|
166
|
+
all is the other thing, and raises.
|
|
167
|
+
|
|
168
|
+
## Configuration
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
Shojiku::Client.new(
|
|
172
|
+
templates: "app/templates", # the template root
|
|
173
|
+
font_dirs: ["vendor/fonts"], # extra font-pack search directories
|
|
174
|
+
locale_dirs: ["vendor/locales"],
|
|
175
|
+
lang: "ja-JP", # overrides the template's own default
|
|
176
|
+
library: "/opt/lib/libshojiku_capi.so",
|
|
177
|
+
logger: Rails.logger, # optional; host events only (see Logging)
|
|
178
|
+
strict: true, # the input ceiling (see below)
|
|
179
|
+
providers: { invoice: … }, # signing providers, by name
|
|
180
|
+
env: true # consult SHOJIKU_* variables at all
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
| Variable | What it sets |
|
|
185
|
+
| --- | --- |
|
|
186
|
+
| `SHOJIKU_TEMPLATE_ROOT` | the template root |
|
|
187
|
+
| `SHOJIKU_FONT_DIR` | font-pack directories (`PATH`-separated) |
|
|
188
|
+
| `SHOJIKU_LOCALE_DIR` | locale-pack directories (`PATH`-separated) |
|
|
189
|
+
| `SHOJIKU_LIBRARY` | the engine library to load |
|
|
190
|
+
|
|
191
|
+
`env: false` disables **all** of them at once, for an application that
|
|
192
|
+
wants its configuration to come from nowhere but its own code.
|
|
193
|
+
|
|
194
|
+
The same settings can be defaults for every client, in the idiom an
|
|
195
|
+
initializer expects:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
Shojiku.configure do |config|
|
|
199
|
+
config.templates = "app/templates"
|
|
200
|
+
config.lang = "ja-JP"
|
|
201
|
+
config.logger = Rails.logger
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
This sets defaults for the constructor; it does not add a precedence
|
|
206
|
+
layer. An explicit argument still wins, and a configured value still
|
|
207
|
+
wins over the environment — with `strict:` the one deliberate exception
|
|
208
|
+
(below). `Shojiku.reset_configuration!` drops the lot, which is what a
|
|
209
|
+
test suite wants between examples.
|
|
210
|
+
|
|
211
|
+
### Locking down where signable input comes from
|
|
212
|
+
|
|
213
|
+
Once you sign what you render, template input is a security boundary:
|
|
214
|
+
whoever controls the bytes controls what gets signed. An operator can
|
|
215
|
+
declare a ceiling:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
Shojiku.configure do |config|
|
|
219
|
+
config.strict = true
|
|
220
|
+
config.providers = { invoice: Shojiku::LocalPem.new(key: "signer.key", cert: "signer.crt") }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
client.sign(document, :invoice) # by name; the key path is not here
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
A strict client:
|
|
227
|
+
|
|
228
|
+
- refuses `generate_source`, so every document it signs came from the
|
|
229
|
+
template root and its containment rules;
|
|
230
|
+
- signs only a document it rendered from that root — bytes handed over
|
|
231
|
+
whole (`client.artifact`) and bytes laid out from a caller's own
|
|
232
|
+
template are the same trust class, and signing does not launder them;
|
|
233
|
+
- takes signing material only as the **name** of a provider registered
|
|
234
|
+
in configuration, so a key path never appears in request-handling code
|
|
235
|
+
and the material is loaded by one object.
|
|
236
|
+
|
|
237
|
+
**Verification is never restricted.** Verifying bytes of unknown
|
|
238
|
+
provenance is the point of verify, and a locked-down deployment is
|
|
239
|
+
exactly the one that has to check an archived document it did not
|
|
240
|
+
produce.
|
|
241
|
+
|
|
242
|
+
Refusals raise `Shojiku::UsageError` rather than returning a failed
|
|
243
|
+
result: strict disables an *entrance*, so calling it is the program
|
|
244
|
+
contradicting its own deployment, not a fact about a document — and a
|
|
245
|
+
failed result is something `if result.success?` can swallow. Strict is
|
|
246
|
+
also the one setting `Shojiku.configure` wins outright: a restriction an
|
|
247
|
+
operator declared must not be lifted by `Client.new(strict: false)`.
|
|
248
|
+
|
|
249
|
+
### Logging
|
|
250
|
+
|
|
251
|
+
Optional, silent by default, and narrow on purpose:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
Shojiku::Client.new(templates: "app/templates", logger: Rails.logger)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Any object answering `debug` is accepted (this gem takes no logger
|
|
258
|
+
dependency). It reports what the *binding* did — which library it
|
|
259
|
+
loaded and why that one, the ABI revision it found, which lifecycle step
|
|
260
|
+
ran, for how long, and whether it worked. It never logs params, document
|
|
261
|
+
bytes, key material, a passphrase, or the engine's diagnostics: a log
|
|
262
|
+
line is the easiest way for a secret to leave a process, and the
|
|
263
|
+
diagnostics already belong to the result you are holding.
|
|
264
|
+
|
|
265
|
+
### Threads
|
|
266
|
+
|
|
267
|
+
A render, a signature and a verification each release Ruby's global VM
|
|
268
|
+
lock for the duration of the call into the engine, so a long render does
|
|
269
|
+
not block other threads in the process. `Client` is safe to share across
|
|
270
|
+
threads, and the engine library itself may be called concurrently — the
|
|
271
|
+
same params produce the same bytes whether one thread is rendering or
|
|
272
|
+
eight are.
|
|
273
|
+
|
|
274
|
+
Explicit configuration wins for the template root and the pack
|
|
275
|
+
directories — what an application renders is the application's own
|
|
276
|
+
decision. `SHOJIKU_LIBRARY` is the one that goes the other way and beats
|
|
277
|
+
an explicit `library:`, because *where the engine lives* is a deployment
|
|
278
|
+
decision that has to be able to override application code. That is the
|
|
279
|
+
same order `SHOJIKU_BIN` has in the SDKs that shell out to the CLI.
|
|
280
|
+
|
|
281
|
+
Nothing in this gem downloads anything, at install time or at run time.
|
|
282
|
+
|
|
283
|
+
## Building the engine library
|
|
284
|
+
|
|
285
|
+
Until the platform gems are published, build the library from a clone:
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
make capi-lib
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
It lands in `dist/capi/local/`; point `SHOJIKU_LIBRARY` at it, or pass
|
|
292
|
+
`library:` to the client.
|
|
293
|
+
|
|
294
|
+
## Development
|
|
295
|
+
|
|
296
|
+
Every gate runs in a container — no Ruby toolchain needed locally:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
make verify:sdk:ruby
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`make test:sdk:ruby` and `make lint:sdk:ruby` are the faster slices.
|
|
303
|
+
|
|
304
|
+
## Requirements
|
|
305
|
+
|
|
306
|
+
Ruby 3.3 or newer.
|
|
307
|
+
|
|
308
|
+
## Documentation
|
|
309
|
+
|
|
310
|
+
- [Template reference](../../docs/engine/README.md) — how to write the
|
|
311
|
+
YAML the engine renders
|
|
312
|
+
- [SDK policy](../../docs/agents/sdk.md) — the lifecycle contract every
|
|
313
|
+
Shojiku SDK implements
|
|
314
|
+
|
|
315
|
+
## License
|
|
316
|
+
|
|
317
|
+
Licensed under any of [Apache-2.0](../../LICENSE-APACHE),
|
|
318
|
+
[MIT](../../LICENSE-MIT), or [BSD-3-Clause](../../LICENSE-BSD), at your
|
|
319
|
+
option.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shojiku
|
|
4
|
+
# A rendered (and possibly signed) document.
|
|
5
|
+
#
|
|
6
|
+
# The application sees bytes and metadata — never a layout-engine internal,
|
|
7
|
+
# and never a handle it has to free. Freeing is the binding's job and it is
|
|
8
|
+
# already done by the time this object exists.
|
|
9
|
+
class DocumentArtifact
|
|
10
|
+
# The PDF, as binary. Always `ASCII-8BIT`: PDF bytes are not text and
|
|
11
|
+
# tagging them as any character encoding is how a document gets corrupted
|
|
12
|
+
# by a well-meaning transcode on the way to disk.
|
|
13
|
+
attr_reader :bytes
|
|
14
|
+
|
|
15
|
+
# How many pages the engine laid out. `nil` for an artifact that was
|
|
16
|
+
# signed rather than rendered — signing appends a revision to bytes it
|
|
17
|
+
# never measured, and a zero there would read as "a document with no
|
|
18
|
+
# pages".
|
|
19
|
+
attr_reader :page_count
|
|
20
|
+
|
|
21
|
+
attr_reader :diagnostics
|
|
22
|
+
|
|
23
|
+
# Where this document came from, which is what a strict client signs on:
|
|
24
|
+
#
|
|
25
|
+
# * `:rendered` — laid out from a template the configured root resolved,
|
|
26
|
+
# * `:source` — laid out from template bytes the application supplied,
|
|
27
|
+
# * `:loaded` — bytes the application supplied whole ({Client#artifact}).
|
|
28
|
+
#
|
|
29
|
+
# Only the first is signable under a {Lockdown}: in the other two the
|
|
30
|
+
# provenance of what gets signed is the application's rather than the
|
|
31
|
+
# deployment's, which is the distinction strict exists to draw. Signing
|
|
32
|
+
# inherits the origin of what it signed — appending a revision does not
|
|
33
|
+
# launder where the document came from. Verification is never restricted.
|
|
34
|
+
attr_reader :origin
|
|
35
|
+
|
|
36
|
+
# `origin` defaults to the LEAST privileged value, not the most: every
|
|
37
|
+
# internal path states it explicitly, so the default only ever applies to
|
|
38
|
+
# an artifact somebody built by hand — which is bytes handed over whole,
|
|
39
|
+
# and must not become signable under a lockdown by omission.
|
|
40
|
+
def initialize(bytes:, diagnostics:, client:, page_count: nil, origin: :loaded)
|
|
41
|
+
@bytes = bytes
|
|
42
|
+
@page_count = page_count
|
|
43
|
+
@diagnostics = diagnostics
|
|
44
|
+
@client = client
|
|
45
|
+
@origin = origin
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether these bytes were handed over whole rather than laid out here.
|
|
49
|
+
def loaded?
|
|
50
|
+
@origin == :loaded
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Writes the document. Binary mode explicitly — a PDF contains NUL and
|
|
54
|
+
# every other byte value, and text mode would translate line endings on
|
|
55
|
+
# Windows.
|
|
56
|
+
def write(path)
|
|
57
|
+
File.binwrite(path, @bytes)
|
|
58
|
+
path
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def size
|
|
62
|
+
@bytes.bytesize
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Signs this document, returning a {Result} carrying the signed artifact.
|
|
66
|
+
# The signed bytes begin with these bytes byte for byte: signing appends a
|
|
67
|
+
# revision, it never rewrites what was there.
|
|
68
|
+
def sign(provider)
|
|
69
|
+
@client.sign(self, provider)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Verifies this document against caller-supplied trust anchors, returning
|
|
73
|
+
# a {Result} carrying a {VerificationReport}.
|
|
74
|
+
def verify(anchors: nil, anchors_pem: nil)
|
|
75
|
+
@client.verify(self, anchors: anchors, anchors_pem: anchors_pem)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Shojiku
|
|
6
|
+
# The entry point: a configured engine, and the sources to render with it.
|
|
7
|
+
#
|
|
8
|
+
# ```ruby
|
|
9
|
+
# client = Shojiku::Client.new(templates: "app/templates")
|
|
10
|
+
# result = client.generate("receipt_ja", customer: { name: "…" })
|
|
11
|
+
# result.artifact.write("receipt.pdf") if result.success?
|
|
12
|
+
# ```
|
|
13
|
+
#
|
|
14
|
+
# **Two entrances, deliberately.** {#generate} takes a template NAME and
|
|
15
|
+
# resolves it against the configured root, which is where the containment
|
|
16
|
+
# rules live. {#generate_source} takes the sources as BYTES the application
|
|
17
|
+
# already has — fetched from object storage, read out of a database, written
|
|
18
|
+
# inline — because fetching is the application's act and this gem downloads
|
|
19
|
+
# nothing. Root containment does not apply to bytes a caller supplied: there
|
|
20
|
+
# is no root to be contained by, which is exactly why a strict client
|
|
21
|
+
# refuses that entrance (see {Lockdown}).
|
|
22
|
+
#
|
|
23
|
+
# **Precedence, and its one deliberate asymmetry.** An explicit `templates:`
|
|
24
|
+
# beats {Shojiku.configure}, which beats `SHOJIKU_TEMPLATE_ROOT`; the pack
|
|
25
|
+
# directories resolve the same way. What an application renders is the
|
|
26
|
+
# application's own decision. An explicit `library:` is the other way round —
|
|
27
|
+
# `SHOJIKU_LIBRARY` beats it — because where the ENGINE lives is an
|
|
28
|
+
# operator's decision that has to be able to win over application code, the
|
|
29
|
+
# same order the subprocess SDKs give `SHOJIKU_BIN`. Passing `env: false`
|
|
30
|
+
# disables every one of those lookups at once. `strict:` is the one setting
|
|
31
|
+
# {Shojiku.configure} wins outright, for the reason in {Lockdown}.
|
|
32
|
+
class Client
|
|
33
|
+
ANCHOR_FORMS = "`anchors:` (paths) or `anchors_pem:` (bytes)"
|
|
34
|
+
|
|
35
|
+
def initialize(templates: nil, font_dirs: nil, locale_dirs: nil, lang: nil, library: nil,
|
|
36
|
+
logger: nil, strict: nil, providers: nil, env: nil)
|
|
37
|
+
@settings = Settings.new(
|
|
38
|
+
templates:, font_dirs:, locale_dirs:, lang:, library:, logger:, strict:, providers:, env:
|
|
39
|
+
)
|
|
40
|
+
@engine = Engine.new(@settings.library)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def template_root
|
|
44
|
+
@settings.template_root
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A copy of this client that renders in `lang`, for the call — or the
|
|
48
|
+
# request — that needs a locale other than this client's own. A
|
|
49
|
+
# multi-locale application renders one template in each buyer's locale
|
|
50
|
+
# without building (or configuring) a second client.
|
|
51
|
+
#
|
|
52
|
+
# **A derived client rather than a `lang:` keyword on {#generate}, and
|
|
53
|
+
# that is a Ruby spelling of a contract every SDK shares.** `generate`
|
|
54
|
+
# takes its params as a trailing Hash, so a keyword argument beside it
|
|
55
|
+
# would turn the natural `generate("receipt", customer: …)` into an
|
|
56
|
+
# `unknown keyword: :customer` error — Ruby resolves that ambiguity
|
|
57
|
+
# against the common case. The other six, whose params are an ordinary
|
|
58
|
+
# argument, express the same override as a per-call option; what they
|
|
59
|
+
# mirror is that a per-call locale beats the client-wide one, not the
|
|
60
|
+
# spelling.
|
|
61
|
+
#
|
|
62
|
+
# The opened library, the template root and the lockdown are shared:
|
|
63
|
+
# deriving a client re-opens nothing.
|
|
64
|
+
def with_lang(lang)
|
|
65
|
+
copy = dup
|
|
66
|
+
copy.settings = @settings.with_lang(lang)
|
|
67
|
+
copy
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# What this build of the engine can do — its version, capability keys and
|
|
71
|
+
# builtin locales. Gate a feature on this rather than on a gem version.
|
|
72
|
+
#
|
|
73
|
+
# A plain Hash, deliberately. The payload is an append-only wire this SDK
|
|
74
|
+
# does not model, exactly as a diagnostic's typed `args` pass through
|
|
75
|
+
# untranslated: a typed value object would have to grow a field in seven
|
|
76
|
+
# languages every time the engine adds one, and an application reading a
|
|
77
|
+
# key its engine is too old to send already has to handle nil.
|
|
78
|
+
def engine_info
|
|
79
|
+
snapshot = @engine.engine_info
|
|
80
|
+
Outcome.guard!(snapshot)
|
|
81
|
+
JSON.parse(snapshot.json)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Renders `name` with `params`, returning a {Result}.
|
|
85
|
+
#
|
|
86
|
+
# `params` may be a Hash (serialized here) or a String you already have —
|
|
87
|
+
# JSON or YAML, since the engine parses either and a String is passed
|
|
88
|
+
# through verbatim. To render in another locale, see {#with_lang}.
|
|
89
|
+
#
|
|
90
|
+
# A rejected template name is a FAILED RESULT, not an exception: a hostile
|
|
91
|
+
# name is a fact about the request, not a bug in the program. A name that
|
|
92
|
+
# is not a String at all IS a bug in the program, and raises.
|
|
93
|
+
def generate(name, params = {})
|
|
94
|
+
sources = template_root!.resolve(name)
|
|
95
|
+
render(sources, params, origin: :rendered, template: Echo.bounded(name))
|
|
96
|
+
rescue TemplateRoot::Rejected => e
|
|
97
|
+
Result.failed(rejection(e, :generate))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Renders sources the APPLICATION supplies, returning a {Result}.
|
|
101
|
+
#
|
|
102
|
+
# For templates that do not live in a directory this gem can see: fetched
|
|
103
|
+
# from object storage, stored in a database, or written inline. Fetching
|
|
104
|
+
# them stays the application's act — nothing here opens a socket.
|
|
105
|
+
#
|
|
106
|
+
# `assets_dir:` is per call rather than per client, because bundled assets
|
|
107
|
+
# belong to a template rather than to a deployment. Without it, bundled
|
|
108
|
+
# image sources are disabled: inline sources have no directory of their
|
|
109
|
+
# own. The locale is overridden the same way as for {#generate}, with
|
|
110
|
+
# {#with_lang} — one spelling, not two.
|
|
111
|
+
def generate_source(template:, definitions: nil, assets_dir: nil, params: {})
|
|
112
|
+
@settings.lockdown.source_entrance!
|
|
113
|
+
sources = Sources.new(template: template, definitions: definitions,
|
|
114
|
+
assets_dir: assets_dir)
|
|
115
|
+
render(sources, params, origin: :source)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Re-enters an archived document, so bytes signed some time ago can be
|
|
119
|
+
# verified — or re-signed — without hand-building an artifact.
|
|
120
|
+
#
|
|
121
|
+
# The result is marked as LOADED: its bytes are the caller's rather than
|
|
122
|
+
# this client's own render, which is a distinction a strict client acts
|
|
123
|
+
# on. `page_count` is nil, honestly: nothing here laid anything out.
|
|
124
|
+
def artifact(bytes)
|
|
125
|
+
DocumentArtifact.new(bytes: bytes, diagnostics: [], client: self, origin: :loaded)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Signs an artifact with `provider`, returning a {Result}. The signed
|
|
129
|
+
# bytes begin with the input byte for byte — signing appends a revision.
|
|
130
|
+
#
|
|
131
|
+
# `provider` is a {LocalPem} (or another provider object), or the NAME of
|
|
132
|
+
# one registered in configuration. A strict client takes the name only.
|
|
133
|
+
def sign(artifact, provider)
|
|
134
|
+
signer = @settings.lockdown.provider!(provider)
|
|
135
|
+
@settings.lockdown.signable!(artifact)
|
|
136
|
+
@settings.log.timed(:sign) { signed(artifact, signer) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Verifies an artifact against trust anchors, returning a {Result} whose
|
|
140
|
+
# value is a {VerificationReport}.
|
|
141
|
+
#
|
|
142
|
+
# Anchors are required and are given as paths (`anchors:`, one or several)
|
|
143
|
+
# or as PEM bytes (`anchors_pem:`, which may carry several concatenated).
|
|
144
|
+
# Which form you passed is explicit rather than sniffed, and passing both
|
|
145
|
+
# raises rather than silently preferring one. There is no fallback to the
|
|
146
|
+
# machine's trust store, because the engine never consults one — a default
|
|
147
|
+
# would answer a different question than you asked.
|
|
148
|
+
#
|
|
149
|
+
# A signature that does not verify is a FAILED result that still carries
|
|
150
|
+
# the report, so `not_checked` reaches you either way.
|
|
151
|
+
def verify(artifact, anchors: nil, anchors_pem: nil)
|
|
152
|
+
pem = anchor_material(anchors, anchors_pem)
|
|
153
|
+
@settings.log.timed(:verify) do
|
|
154
|
+
Outcome.verdict(@engine.verify(pdf: artifact.bytes, anchors: pem))
|
|
155
|
+
end
|
|
156
|
+
rescue MaterialUnreadable => e
|
|
157
|
+
Result.failed(Failure.new(step: :verify, kind: e.kind, message: e.message))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
protected
|
|
161
|
+
|
|
162
|
+
# Written only by {#with_lang}, on a copy that is not yet visible to
|
|
163
|
+
# anyone — a derived client is built, not mutated.
|
|
164
|
+
attr_writer :settings
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def render(sources, params, origin:, **fields)
|
|
169
|
+
request = Request.new(sources: sources, params: params, lang: @settings.lang,
|
|
170
|
+
font_dirs: @settings.font_dirs, locale_dirs: @settings.locale_dirs)
|
|
171
|
+
@settings.log.timed(:generate, **fields) do
|
|
172
|
+
snapshot = @engine.render(request.json)
|
|
173
|
+
Outcome.document(snapshot, step: :generate, client: self, origin: origin)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The signed document inherits the origin of what it signed: appending a
|
|
178
|
+
# revision does not launder where the document came from.
|
|
179
|
+
def signed(artifact, provider)
|
|
180
|
+
snapshot = @engine.sign(
|
|
181
|
+
pdf: artifact.bytes, key: provider.key, certificate: provider.certificate,
|
|
182
|
+
passphrase: provider.passphrase
|
|
183
|
+
)
|
|
184
|
+
Outcome.document(snapshot, step: :sign, client: self, origin: artifact.origin)
|
|
185
|
+
rescue MaterialUnreadable => e
|
|
186
|
+
Result.failed(Failure.new(step: :sign, kind: e.kind, message: e.message))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def template_root!
|
|
190
|
+
return template_root if template_root
|
|
191
|
+
|
|
192
|
+
raise UsageError,
|
|
193
|
+
"no template root: pass Shojiku::Client.new(templates: …), set it with " \
|
|
194
|
+
"Shojiku.configure, or set SHOJIKU_TEMPLATE_ROOT (which `env: false` " \
|
|
195
|
+
"disables). Sources you already hold go to `generate_source`."
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def anchor_material(paths, pem)
|
|
199
|
+
raise UsageError, "verify takes either #{ANCHOR_FORMS}, not both" if paths && pem
|
|
200
|
+
return pem if pem
|
|
201
|
+
raise UsageError, "verify needs #{ANCHOR_FORMS}" if paths.nil?
|
|
202
|
+
|
|
203
|
+
Array(paths).map { |path| Material.read(path, "anchor_unreadable") }.join("\n")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def rejection(error, step)
|
|
207
|
+
cause = if error.cause_message
|
|
208
|
+
Failure.new(step: step, kind: "io", message: error.cause_message)
|
|
209
|
+
end
|
|
210
|
+
Failure.new(step: step, kind: error.kind, message: error.message, cause: cause)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|