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,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shojiku
|
|
4
|
+
# The input ceiling an operator can declare, and the named signing
|
|
5
|
+
# providers that go with it.
|
|
6
|
+
#
|
|
7
|
+
# Once signing is in the loop, template input is a security boundary:
|
|
8
|
+
# whoever controls the bytes controls what gets signed. A strict client
|
|
9
|
+
# therefore narrows where signable input may come from.
|
|
10
|
+
#
|
|
11
|
+
# * The bytes-first entrance ({Client#generate_source}) is refused, so
|
|
12
|
+
# every document this client signs came from the configured template
|
|
13
|
+
# root, with its containment rules.
|
|
14
|
+
# * An artifact this client did not render ({Client#artifact}) may not be
|
|
15
|
+
# signed — those bytes are the caller's, exactly like a bytes-first
|
|
16
|
+
# template.
|
|
17
|
+
# * Signing material must be a provider REGISTERED in configuration and
|
|
18
|
+
# named at the call site, so a key path never appears in
|
|
19
|
+
# request-handling code and the material is loaded by one object rather
|
|
20
|
+
# than rebuilt per request.
|
|
21
|
+
#
|
|
22
|
+
# **Verification is never restricted.** Verifying bytes of unknown
|
|
23
|
+
# provenance is the entire point of verify, and a locked-down deployment is
|
|
24
|
+
# precisely the one that needs to check an archived document it did not
|
|
25
|
+
# produce.
|
|
26
|
+
#
|
|
27
|
+
# Refusals raise {UsageError} rather than returning a failed {Result}:
|
|
28
|
+
# strict disables an ENTRANCE, so calling it is the program contradicting
|
|
29
|
+
# its own deployment's configuration — not a fact about a document — and a
|
|
30
|
+
# failed result is something `if result.success?` can swallow.
|
|
31
|
+
#
|
|
32
|
+
# The six other SDKs mirror this with identical semantics. It is contract,
|
|
33
|
+
# not ecosystem idiom.
|
|
34
|
+
class Lockdown
|
|
35
|
+
def initialize(strict:, providers: nil)
|
|
36
|
+
@strict = strict
|
|
37
|
+
# Registered under symbols whatever the caller wrote them as. A
|
|
38
|
+
# configuration hash keyed by strings is the ordinary Ruby spelling,
|
|
39
|
+
# and looking it up only by symbol would answer "no signing provider
|
|
40
|
+
# named `invoice` is registered" for a provider named exactly that.
|
|
41
|
+
@providers = (providers || {}).transform_keys(&:to_sym)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def strict?
|
|
45
|
+
@strict
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The bytes-first entrance.
|
|
49
|
+
def source_entrance!
|
|
50
|
+
return unless @strict
|
|
51
|
+
|
|
52
|
+
raise UsageError,
|
|
53
|
+
"this client is strict: templates must come from the template root, so " \
|
|
54
|
+
"`generate_source` is disabled. Use `generate(name, params)`."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# An artifact about to be signed. Only a document laid out from a template
|
|
58
|
+
# the ROOT resolved qualifies — bytes handed over whole, and bytes laid
|
|
59
|
+
# out from a caller's own template, are the same trust class here. That
|
|
60
|
+
# closes the gap a boolean "was it loaded" would leave open: an artifact
|
|
61
|
+
# from another client's bytes-first render is not this deployment's
|
|
62
|
+
# document either.
|
|
63
|
+
def signable!(artifact)
|
|
64
|
+
return unless @strict && artifact.origin != :rendered
|
|
65
|
+
|
|
66
|
+
raise UsageError,
|
|
67
|
+
"this client is strict: only a document rendered from its own template " \
|
|
68
|
+
"root may be signed (this one is #{artifact.origin}). It can still be " \
|
|
69
|
+
"verified."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The provider to sign with.
|
|
73
|
+
#
|
|
74
|
+
# A Symbol or String is a registered name, in strict mode and out of it —
|
|
75
|
+
# naming providers is good practice everywhere, and only the REFUSAL of
|
|
76
|
+
# the alternative is strict's. A provider object is accepted only when
|
|
77
|
+
# this client is not strict.
|
|
78
|
+
def provider!(provider)
|
|
79
|
+
return registered!(provider) if provider.is_a?(Symbol) || provider.is_a?(String)
|
|
80
|
+
return provider unless @strict
|
|
81
|
+
|
|
82
|
+
raise UsageError,
|
|
83
|
+
"this client is strict: sign with the name of a provider registered in " \
|
|
84
|
+
"configuration, not with a provider object."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def registered!(name)
|
|
90
|
+
@providers.fetch(name.to_sym) do
|
|
91
|
+
raise UsageError, "no signing provider named `#{Echo.bounded(name)}` is registered"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/shojiku/log.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shojiku
|
|
4
|
+
# The optional host-side log channel.
|
|
5
|
+
#
|
|
6
|
+
# Silent unless an application supplies a logger, and deliberately narrow:
|
|
7
|
+
# it reports what the BINDING did — which library it loaded, which ABI
|
|
8
|
+
# revision it found, which lifecycle step ran and for how long — and never
|
|
9
|
+
# what the document contained. Params, rendered bytes, diagnostics and key
|
|
10
|
+
# material are all outside this channel BY RULE, because a log line is the
|
|
11
|
+
# easiest way for a secret to leave a process, and because a diagnostic
|
|
12
|
+
# belongs to the {Result} the caller already has.
|
|
13
|
+
#
|
|
14
|
+
# What does cross is bounded first ({Echo}), so a hostile template name
|
|
15
|
+
# cannot smuggle control characters into a log file.
|
|
16
|
+
#
|
|
17
|
+
# Any object answering `debug` is accepted — `Logger`, `Rails.logger`, or an
|
|
18
|
+
# application's own — so the gem's runtime dependency list stays at exactly
|
|
19
|
+
# one entry. The cross-language rule the other six mirror: each SDK accepts
|
|
20
|
+
# its ecosystem's standard logger interface, optionally.
|
|
21
|
+
class Log
|
|
22
|
+
def initialize(logger = nil)
|
|
23
|
+
@logger = logger
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Records one host event. The message is built only when someone is
|
|
27
|
+
# listening: a silent log costs a nil check, not string formatting.
|
|
28
|
+
def event(name, **fields)
|
|
29
|
+
return unless @logger
|
|
30
|
+
|
|
31
|
+
@logger.debug("shojiku #{name}#{render(fields)}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Times one lifecycle operation and returns what the block returned.
|
|
35
|
+
#
|
|
36
|
+
# The block is expected to produce a {Result}, whose verdict is recorded
|
|
37
|
+
# as `ok` — the one thing worth knowing about an operation that is not
|
|
38
|
+
# its content.
|
|
39
|
+
def timed(name, **fields)
|
|
40
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
|
+
result = yield
|
|
42
|
+
event(name, **fields, ms: elapsed_ms(started), ok: result.success?)
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def elapsed_ms(started)
|
|
49
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render(fields)
|
|
53
|
+
fields.map { |key, value| " #{key}=#{value}" }.join
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shojiku
|
|
4
|
+
# Turning one engine {Snapshot} into the {Result} an application sees.
|
|
5
|
+
#
|
|
6
|
+
# The C surface's two levels of failure meet here, and keeping them apart is
|
|
7
|
+
# the whole job: a non-zero status is the CALLER's mistake and raises, while
|
|
8
|
+
# everything a DOCUMENT can do wrong comes back as a failed result with the
|
|
9
|
+
# engine's diagnostics attached.
|
|
10
|
+
module Outcome
|
|
11
|
+
class << self
|
|
12
|
+
# A non-zero status is the C surface saying the CALLER got it wrong — a
|
|
13
|
+
# null pointer, a request the schema rejects, an argument past a hard
|
|
14
|
+
# cap. That is programmer misuse in Ruby terms, so it raises.
|
|
15
|
+
def guard!(snapshot)
|
|
16
|
+
return if snapshot.status.zero?
|
|
17
|
+
|
|
18
|
+
raise UsageError,
|
|
19
|
+
"the engine refused the call (status #{snapshot.status}): #{snapshot.error}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A rendered or signed document. Diagnostics are attached either way: a
|
|
23
|
+
# render that WORKED can still have warned.
|
|
24
|
+
def document(snapshot, step:, client:, origin:)
|
|
25
|
+
guard!(snapshot)
|
|
26
|
+
diagnostics = Diagnostic.parse(snapshot.diagnostics)
|
|
27
|
+
return refused(snapshot, step, diagnostics) unless snapshot.success
|
|
28
|
+
|
|
29
|
+
Result.succeeded(artifact(snapshot, diagnostics, client, origin), diagnostics)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A verification verdict.
|
|
33
|
+
#
|
|
34
|
+
# The report is parsed BEFORE the verdict is read, because it rides a
|
|
35
|
+
# FAILED verify too — that is the whole point of carrying `not_checked`.
|
|
36
|
+
# Diagnostics are parsed on both paths for the same reason they are on a
|
|
37
|
+
# render: whatever the engine noticed belongs to the caller, and an
|
|
38
|
+
# operation that drops them makes its result mean something different
|
|
39
|
+
# from every other operation's.
|
|
40
|
+
def verdict(snapshot)
|
|
41
|
+
guard!(snapshot)
|
|
42
|
+
diagnostics = Diagnostic.parse(snapshot.diagnostics)
|
|
43
|
+
report = snapshot.json.empty? ? nil : VerificationReport.parse(snapshot.json)
|
|
44
|
+
return Result.succeeded(report, diagnostics) if snapshot.success
|
|
45
|
+
|
|
46
|
+
failure = Failure.from_error_json(
|
|
47
|
+
snapshot.error, step: :verify, diagnostics: diagnostics
|
|
48
|
+
)
|
|
49
|
+
Result.new(value: report, diagnostics: diagnostics, failure: failure)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def artifact(snapshot, diagnostics, client, origin)
|
|
55
|
+
DocumentArtifact.new(
|
|
56
|
+
bytes: snapshot.pdf, diagnostics: diagnostics, client: client,
|
|
57
|
+
page_count: page_count(snapshot.json), origin: origin
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def refused(snapshot, step, diagnostics)
|
|
62
|
+
Result.failed(
|
|
63
|
+
Failure.from_error_json(snapshot.error, step: step, diagnostics: diagnostics)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Absent (not zero) on a signed artifact: signing appends a revision to
|
|
68
|
+
# bytes it never laid out, and the surface returns no JSON payload for
|
|
69
|
+
# it at all.
|
|
70
|
+
def page_count(json)
|
|
71
|
+
json.empty? ? nil : JSON.parse(json)["pageCount"]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shojiku
|
|
4
|
+
# The one JSON envelope every document operation crosses with.
|
|
5
|
+
#
|
|
6
|
+
# Both entrances build it: sources resolved from a template NAME and
|
|
7
|
+
# sources the application handed over as BYTES produce the same request,
|
|
8
|
+
# because the C surface has one request schema — and that schema rejects
|
|
9
|
+
# unknown keys, so a key the engine may legitimately not receive is dropped
|
|
10
|
+
# rather than sent as null.
|
|
11
|
+
class Request
|
|
12
|
+
def initialize(sources:, params:, lang: nil, font_dirs: [], locale_dirs: [])
|
|
13
|
+
@sources = sources
|
|
14
|
+
@params = params
|
|
15
|
+
@lang = lang
|
|
16
|
+
@font_dirs = font_dirs
|
|
17
|
+
@locale_dirs = locale_dirs
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The serialized envelope, turning the one failure JSON generation has
|
|
21
|
+
# into this gem's own exception.
|
|
22
|
+
#
|
|
23
|
+
# Params that are not valid UTF-8 are programmer misuse — the engine's
|
|
24
|
+
# surface is UTF-8 by contract, so there is nothing to render — but a bare
|
|
25
|
+
# `JSON::GeneratorError` escaping from `generate` would make callers
|
|
26
|
+
# rescue a foreign class they never invited into their code.
|
|
27
|
+
def json
|
|
28
|
+
JSON.generate(envelope)
|
|
29
|
+
rescue JSON::GeneratorError, Encoding::UndefinedConversionError => e
|
|
30
|
+
raise UsageError, "params could not be serialized as UTF-8 JSON: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def envelope
|
|
36
|
+
{
|
|
37
|
+
template: @sources.template,
|
|
38
|
+
definitions: @sources.definitions,
|
|
39
|
+
params: params_source,
|
|
40
|
+
lang: @lang,
|
|
41
|
+
fontDirs: @font_dirs,
|
|
42
|
+
localeDirs: @locale_dirs,
|
|
43
|
+
assetsDir: @sources.assets_dir
|
|
44
|
+
}.compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A String params is the caller's own source text, passed through
|
|
48
|
+
# VERBATIM: the engine parses JSON or YAML (YAML is a superset), so
|
|
49
|
+
# re-encoding it here would only be a chance to change it. Anything else
|
|
50
|
+
# is serialized as JSON.
|
|
51
|
+
#
|
|
52
|
+
# There is deliberately no per-format method family — format dispatch is
|
|
53
|
+
# the engine's, and an SDK that offered `generate_yaml` would be claiming
|
|
54
|
+
# a distinction the engine does not make.
|
|
55
|
+
def params_source
|
|
56
|
+
@params.is_a?(String) ? @params : JSON.generate(@params)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shojiku
|
|
4
|
+
# What every lifecycle operation returns.
|
|
5
|
+
#
|
|
6
|
+
# Nothing in the normal flow raises. A template that will not render, a key
|
|
7
|
+
# that will not sign, a signature that does not verify are all data you
|
|
8
|
+
# query — `success?`, the value, the engine's diagnostics either way, and on
|
|
9
|
+
# failure the {Failure} trace.
|
|
10
|
+
#
|
|
11
|
+
# Diagnostics ride on a SUCCESS too. A render that worked can still have
|
|
12
|
+
# warned about an overflowing box, and a caller that only looks at failures
|
|
13
|
+
# never sees them.
|
|
14
|
+
class Result
|
|
15
|
+
attr_reader :value, :diagnostics, :failure
|
|
16
|
+
|
|
17
|
+
def self.succeeded(value, diagnostics)
|
|
18
|
+
new(value: value, diagnostics: diagnostics)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.failed(failure)
|
|
22
|
+
new(failure: failure, diagnostics: failure.diagnostics)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(value: nil, diagnostics: [], failure: nil)
|
|
26
|
+
@value = value
|
|
27
|
+
@diagnostics = diagnostics
|
|
28
|
+
@failure = failure
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def success?
|
|
32
|
+
@failure.nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def failure?
|
|
36
|
+
!success?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `value` under the name of what the operation produced. Both are the same
|
|
40
|
+
# object; the aliases exist so calling code reads as what it is doing.
|
|
41
|
+
alias artifact value
|
|
42
|
+
alias report value
|
|
43
|
+
|
|
44
|
+
# The value, or a raised {UnwrapError} when the operation failed.
|
|
45
|
+
#
|
|
46
|
+
# The opt-in bridge for a script that wants a stack trace rather than a
|
|
47
|
+
# branch, and the ONE place this API raises for something other than a
|
|
48
|
+
# misused argument. That is why the ruling is stated rather than implied,
|
|
49
|
+
# and frozen for every Shojiku SDK: **calling unwrap on a failed result is
|
|
50
|
+
# programmer misuse** — a caller who has not checked `success?` is
|
|
51
|
+
# asserting the operation worked. Application code that handles failure
|
|
52
|
+
# keeps using `success?` and {#failure}; nothing in this gem calls these.
|
|
53
|
+
#
|
|
54
|
+
# (Go is the recorded exception: the language has no exceptions, so its
|
|
55
|
+
# SDK mirrors the shape as an error return rather than a panic.)
|
|
56
|
+
def value!
|
|
57
|
+
raise UnwrapError, @failure if @failure
|
|
58
|
+
|
|
59
|
+
@value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
alias artifact! value!
|
|
63
|
+
alias report! value!
|
|
64
|
+
|
|
65
|
+
# Only the diagnostics that are errors — the ones that explain a refusal.
|
|
66
|
+
def errors
|
|
67
|
+
@diagnostics.select(&:error?)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Only the warnings, which a SUCCESSFUL result can carry.
|
|
71
|
+
def warnings
|
|
72
|
+
@diagnostics.select(&:warning?)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shojiku
|
|
4
|
+
# One client's resolved configuration, plus the collaborators built from it.
|
|
5
|
+
#
|
|
6
|
+
# {Config} answers "what was configured"; this answers "what does THIS
|
|
7
|
+
# client use", which is the merge of the process-wide defaults with the
|
|
8
|
+
# arguments the client was constructed with. Keeping it out of {Client}
|
|
9
|
+
# keeps the precedence rules in one readable place instead of spread across
|
|
10
|
+
# a constructor.
|
|
11
|
+
#
|
|
12
|
+
# Everything is built lazily and memoized: a bytes-first application never
|
|
13
|
+
# configures a template root, and demanding one at construction would refuse
|
|
14
|
+
# a legitimate client.
|
|
15
|
+
class Settings
|
|
16
|
+
attr_reader :lang
|
|
17
|
+
|
|
18
|
+
def initialize(**overrides)
|
|
19
|
+
@config = Shojiku.config.merge(overrides)
|
|
20
|
+
@lang = @config.lang
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A copy that renders in `lang`, for {Client#with_lang}. Everything
|
|
24
|
+
# already built — the opened library, the template root, the lockdown —
|
|
25
|
+
# is carried over by `dup`, so deriving a client re-opens nothing.
|
|
26
|
+
def with_lang(lang)
|
|
27
|
+
copy = dup
|
|
28
|
+
copy.override_lang(lang)
|
|
29
|
+
copy
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def env
|
|
33
|
+
@env ||= Env.new(enabled: @config.env)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def log
|
|
37
|
+
@log ||= Log.new(@config.logger)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def lockdown
|
|
41
|
+
@lockdown ||= Lockdown.new(strict: @config.strict, providers: @config.providers)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def library
|
|
45
|
+
@library ||= Library.new(path: @config.library, env: env, log: log)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def font_dirs
|
|
49
|
+
@font_dirs ||= @config.font_dirs || env.paths("SHOJIKU_FONT_DIR")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def locale_dirs
|
|
53
|
+
@locale_dirs ||= @config.locale_dirs || env.paths("SHOJIKU_LOCALE_DIR")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The template root, or nil when nothing configured one.
|
|
57
|
+
#
|
|
58
|
+
# `defined?` rather than `||=` because nil is a legitimate answer here and
|
|
59
|
+
# would otherwise be re-resolved on every call.
|
|
60
|
+
def template_root
|
|
61
|
+
return @template_root if defined?(@template_root)
|
|
62
|
+
|
|
63
|
+
root = @config.templates || env["SHOJIKU_TEMPLATE_ROOT"]
|
|
64
|
+
@template_root = root ? TemplateRoot.new(root) : nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
protected
|
|
68
|
+
|
|
69
|
+
# Only {#with_lang} calls this, on a copy nobody else can see yet.
|
|
70
|
+
def override_lang(lang)
|
|
71
|
+
@lang = lang
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shojiku
|
|
4
|
+
# The sources one render runs over: the template text, the definitions text
|
|
5
|
+
# when there are any, and the directory bundled assets resolve against.
|
|
6
|
+
#
|
|
7
|
+
# A value rather than a file layout, because there are two ways to get one
|
|
8
|
+
# and only one of them involves the filesystem. {TemplateRoot} produces it
|
|
9
|
+
# by resolving a NAME; {Client#generate_source} produces it from bytes the
|
|
10
|
+
# application already has. Everything downstream — the request envelope, the
|
|
11
|
+
# engine — sees the same object either way, which is what keeps the second
|
|
12
|
+
# entrance from being a second code path.
|
|
13
|
+
Sources = Data.define(:template, :definitions, :assets_dir)
|
|
14
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shojiku
|
|
4
|
+
# Resolving a template NAME to the sources behind it.
|
|
5
|
+
#
|
|
6
|
+
# A name is an identifier, never a path. A bundle format will take this
|
|
7
|
+
# lookup over later, so nothing outside this class may assume a directory is
|
|
8
|
+
# how names resolve — callers ask for `"receipt_ja"` and get sources back.
|
|
9
|
+
#
|
|
10
|
+
# **The rejection rules are the union across platforms, not the host's.**
|
|
11
|
+
# Windows is a first-class target (it is what the .NET SDK's market runs
|
|
12
|
+
# on), so a backslash is a separator, `C:name` is drive-relative,
|
|
13
|
+
# `\\host\share` is a UNC path and `CON`/`NUL` are reserved devices —
|
|
14
|
+
# every one of them refused on EVERY platform. A template name that is
|
|
15
|
+
# valid on one machine is valid on all of them, which is the only way the
|
|
16
|
+
# same application deploys to both.
|
|
17
|
+
class TemplateRoot
|
|
18
|
+
# Reserved DOS device names. Windows resolves these no matter what
|
|
19
|
+
# directory you are in and no matter what extension you append.
|
|
20
|
+
DEVICES = (%w[CON PRN AUX NUL] +
|
|
21
|
+
(1..9).flat_map { |n| ["COM#{n}", "LPT#{n}"] }).freeze
|
|
22
|
+
|
|
23
|
+
# A name is ONE segment. Refusing both separators outright subsumes
|
|
24
|
+
# traversal, absolute paths and nested lookups in a single rule — the
|
|
25
|
+
# simplest thing six other SDKs can mirror without drifting.
|
|
26
|
+
SEPARATORS = %r{[/\\]}
|
|
27
|
+
DRIVE_RELATIVE = /\A[A-Za-z]:/
|
|
28
|
+
CONTROL = /[\x00-\x1f\x7f]/
|
|
29
|
+
|
|
30
|
+
TEMPLATE_FILE = "templates.yml"
|
|
31
|
+
DEFINITIONS_FILE = "definitions.yml"
|
|
32
|
+
|
|
33
|
+
# Each rule, and what a caller is told when it fires. The keys are the
|
|
34
|
+
# predicate names below, so adding a rule is one entry plus one method.
|
|
35
|
+
RULES = {
|
|
36
|
+
"separator" => "a name is one segment, so `/` and `\\` are never part of it " \
|
|
37
|
+
"(which is also what makes `..` traversal impossible)",
|
|
38
|
+
"control" => "it contains a control character",
|
|
39
|
+
"drive_relative" => "it is drive-relative, which Windows resolves against " \
|
|
40
|
+
"that drive's current directory",
|
|
41
|
+
"device" => "it is a reserved device name on Windows"
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
attr_reader :path
|
|
45
|
+
|
|
46
|
+
def initialize(path)
|
|
47
|
+
@path = path
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolves `name`, or raises {Rejected} naming why it will not.
|
|
51
|
+
#
|
|
52
|
+
# Rejection is an exception INSIDE this class and a failed Result outside
|
|
53
|
+
# it (see {Client#generate}) — a hostile template name is a fact about the
|
|
54
|
+
# request, not a bug in the calling program.
|
|
55
|
+
def resolve(name)
|
|
56
|
+
identifier!(name)
|
|
57
|
+
reject!(name)
|
|
58
|
+
dir = File.join(@path, name)
|
|
59
|
+
real = contained!(dir)
|
|
60
|
+
Sources.new(
|
|
61
|
+
template: read!(File.join(real, TEMPLATE_FILE)),
|
|
62
|
+
definitions: optional(File.join(real, DEFINITIONS_FILE)),
|
|
63
|
+
assets_dir: real
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A refused name or an unreadable template, with the machine-readable
|
|
68
|
+
# `kind` the failure trace carries.
|
|
69
|
+
class Rejected < StandardError
|
|
70
|
+
attr_reader :kind, :cause_message
|
|
71
|
+
|
|
72
|
+
def initialize(kind, message, cause_message: nil)
|
|
73
|
+
@kind = kind
|
|
74
|
+
@cause_message = cause_message
|
|
75
|
+
super(message)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# A name is an IDENTIFIER, so anything that is not a string is a bug in
|
|
82
|
+
# the calling program rather than a hostile request — and it has to be
|
|
83
|
+
# caught here, because a Symbol otherwise passes every rule below (a
|
|
84
|
+
# Regexp matches one happily) and dies inside `File.join` as a `TypeError`
|
|
85
|
+
# from a stdlib method the caller never called.
|
|
86
|
+
#
|
|
87
|
+
# A BLANK string is the other case and stays a refused request: it can
|
|
88
|
+
# arrive straight from a form field.
|
|
89
|
+
def identifier!(name)
|
|
90
|
+
return if name.is_a?(String)
|
|
91
|
+
|
|
92
|
+
raise UsageError,
|
|
93
|
+
"a template name must be a String; got #{name.class}. Sources you " \
|
|
94
|
+
"already hold go to `generate_source`."
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def reject!(name)
|
|
98
|
+
raise Rejected.new("template_name", "a template name must not be empty") if blank?(name)
|
|
99
|
+
|
|
100
|
+
RULES.each_key do |rule|
|
|
101
|
+
next unless send(:"#{rule}?", name)
|
|
102
|
+
|
|
103
|
+
raise Rejected.new(
|
|
104
|
+
"template_name",
|
|
105
|
+
"`#{Echo.bounded(name)}` is not a template name: #{RULES.fetch(rule)}"
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def blank?(name)
|
|
111
|
+
name.strip.empty?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def separator?(name)
|
|
115
|
+
SEPARATORS.match?(name)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def control?(name)
|
|
119
|
+
CONTROL.match?(name)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def drive_relative?(name)
|
|
123
|
+
DRIVE_RELATIVE.match?(name)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Trailing dots and spaces are STRIPPED by Windows before it resolves a
|
|
127
|
+
# name, so `CON.` and `"CON "` are the CON device just as `CON` is.
|
|
128
|
+
# Without that strip they slip past this rule and are refused later, by
|
|
129
|
+
# containment — still refused, but with a message about a missing
|
|
130
|
+
# template rather than about a reserved name.
|
|
131
|
+
def device?(name)
|
|
132
|
+
stem = name.split(".").first.to_s.sub(/[.\s]+\z/, "")
|
|
133
|
+
DEVICES.include?(stem.upcase)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The check a name-shape rule cannot make: after following whatever the
|
|
137
|
+
# filesystem has there, is the answer still inside the root? A symlink is
|
|
138
|
+
# what this exists for — it passes every rule above and still points out.
|
|
139
|
+
def contained!(dir)
|
|
140
|
+
root = File.realpath(@path)
|
|
141
|
+
real = File.realpath(dir)
|
|
142
|
+
inside = real == root || real.start_with?("#{root}#{File::SEPARATOR}")
|
|
143
|
+
return real if inside
|
|
144
|
+
|
|
145
|
+
raise Rejected.new("template_escapes_root",
|
|
146
|
+
"the template resolves outside the template root")
|
|
147
|
+
rescue Errno::ENOENT, Errno::ENOTDIR => e
|
|
148
|
+
raise Rejected.new("template_not_found", "no template by that name", cause_message: e.message)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def read!(path)
|
|
152
|
+
File.read(path, encoding: Encoding::UTF_8)
|
|
153
|
+
rescue SystemCallError => e
|
|
154
|
+
raise Rejected.new("template_unreadable", "the template could not be read",
|
|
155
|
+
cause_message: e.message)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def optional(path)
|
|
159
|
+
File.file?(path) ? read!(path) : nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shojiku
|
|
4
|
+
# What verification found — INCLUDING what it did not look at.
|
|
5
|
+
#
|
|
6
|
+
# `not_checked` is a field, not a footnote, and this binding passes it
|
|
7
|
+
# through untouched. A "valid" verdict that quietly skipped revocation is
|
|
8
|
+
# worse than no verifier at all: it turns a missing capability into a false
|
|
9
|
+
# assurance, which is exactly the trust a signing feature sells. Dropping it
|
|
10
|
+
# on the way through an SDK would be the same lie one layer up.
|
|
11
|
+
#
|
|
12
|
+
# The four checks stay separate for the same reason. "The signature is valid
|
|
13
|
+
# but covers only part of the file" is a different fact from "the signature
|
|
14
|
+
# is wrong", and a caller that cannot tell them apart cannot explain the
|
|
15
|
+
# answer to anyone.
|
|
16
|
+
class VerificationReport
|
|
17
|
+
# The outcome of one check: `passed`, or `failed` with the reason.
|
|
18
|
+
class Check
|
|
19
|
+
attr_reader :status, :reason
|
|
20
|
+
|
|
21
|
+
def initialize(item)
|
|
22
|
+
@status = item["status"]
|
|
23
|
+
@reason = item["reason"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def passed?
|
|
27
|
+
@status == "passed"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_s
|
|
31
|
+
@reason ? "#{@status}: #{@reason}" : @status.to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :signature, :coverage, :certificate_validity, :trust_chain, :not_checked
|
|
36
|
+
|
|
37
|
+
def self.parse(json)
|
|
38
|
+
new(JSON.parse(json))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(payload)
|
|
42
|
+
@valid = payload["valid"]
|
|
43
|
+
@signature = Check.new(payload["signature"])
|
|
44
|
+
@coverage = Check.new(payload["coverage"])
|
|
45
|
+
@certificate_validity = Check.new(payload["certificateValidity"])
|
|
46
|
+
@trust_chain = Check.new(payload["trustChain"])
|
|
47
|
+
@not_checked = Array(payload["notChecked"]).map(&:to_sym).freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether every check this release PERFORMS passed. Read `not_checked`
|
|
51
|
+
# beside it: this is not "the document is trustworthy", it is "nothing we
|
|
52
|
+
# looked at was wrong".
|
|
53
|
+
def valid?
|
|
54
|
+
@valid == true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def checks
|
|
58
|
+
{ signature: @signature, coverage: @coverage,
|
|
59
|
+
certificate_validity: @certificate_validity, trust_chain: @trust_chain }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|