shojiku 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shojiku
4
+ # Tracks the engine workspace version. All seven SDKs move together while
5
+ # everything is pre-1.0 and publish together at the first public release.
6
+ VERSION = "0.1.0"
7
+ end