hanko_xml 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/CHANGELOG.md +44 -0
- data/Cargo.lock +401 -0
- data/Cargo.toml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +222 -0
- data/THIRD_PARTY_LICENSES.md +392 -0
- data/dependencies.yml +87 -0
- data/ext/hanko_xml/Cargo.toml +20 -0
- data/ext/hanko_xml/build/vendor.rs +817 -0
- data/ext/hanko_xml/build.rs +298 -0
- data/ext/hanko_xml/extconf.rb +6 -0
- data/ext/hanko_xml/src/dsig.rs +1851 -0
- data/ext/hanko_xml/src/errors.rs +151 -0
- data/ext/hanko_xml/src/lib.rs +490 -0
- data/ext/hanko_xml/src/sys.rs +43 -0
- data/lib/hanko_xml/algorithms.rb +228 -0
- data/lib/hanko_xml/dom.rb +137 -0
- data/lib/hanko_xml/errors.rb +132 -0
- data/lib/hanko_xml/exc-c14n.xsd +26 -0
- data/lib/hanko_xml/input.rb +50 -0
- data/lib/hanko_xml/key.rb +213 -0
- data/lib/hanko_xml/key_info.rb +67 -0
- data/lib/hanko_xml/native_version.rb +75 -0
- data/lib/hanko_xml/nokogiri_ext.rb +34 -0
- data/lib/hanko_xml/placement.rb +127 -0
- data/lib/hanko_xml/policy.rb +502 -0
- data/lib/hanko_xml/reference.rb +49 -0
- data/lib/hanko_xml/signature_info.rb +134 -0
- data/lib/hanko_xml/signed_document.rb +155 -0
- data/lib/hanko_xml/signer.rb +572 -0
- data/lib/hanko_xml/template.rb +150 -0
- data/lib/hanko_xml/transform.rb +87 -0
- data/lib/hanko_xml/verification.rb +377 -0
- data/lib/hanko_xml/verifier.rb +694 -0
- data/lib/hanko_xml/version.rb +5 -0
- data/lib/hanko_xml/xades/verifier.rb +319 -0
- data/lib/hanko_xml/xades.rb +287 -0
- data/lib/hanko_xml/xml.rb +93 -0
- data/lib/hanko_xml/xmldsig-core-schema.xsd +332 -0
- data/lib/hanko_xml.rb +122 -0
- data/sig/hanko_xml/key.rbs +48 -0
- data/sig/hanko_xml/native.rbs +12 -0
- data/sig/hanko_xml/native_version.rbs +13 -0
- data/sig/hanko_xml/nokogiri_ext.rbs +23 -0
- data/sig/hanko_xml/placement.rbs +27 -0
- data/sig/hanko_xml/policy.rbs +83 -0
- data/sig/hanko_xml/reference.rbs +36 -0
- data/sig/hanko_xml/signed_document.rbs +66 -0
- data/sig/hanko_xml/signer.rbs +50 -0
- data/sig/hanko_xml/template.rbs +41 -0
- data/sig/hanko_xml/verification.rbs +100 -0
- data/sig/hanko_xml/verifier.rbs +45 -0
- data/sig/hanko_xml/xades.rbs +70 -0
- data/sig/hanko_xml.rbs +62 -0
- metadata +105 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module HankoXml
|
|
6
|
+
# Explicit wrapper around key/certificate material. See
|
|
7
|
+
# API_CONTRACT.md §5.6.
|
|
8
|
+
#
|
|
9
|
+
# Everything crossing into the native layer is PEM, always -- Key
|
|
10
|
+
# normalizes with OpenSSL::PKey#to_pem / OpenSSL::X509::Certificate#to_pem.
|
|
11
|
+
#
|
|
12
|
+
# A Key never appears in #inspect or #to_s, and its constructor filters
|
|
13
|
+
# any value coming from a Key out of exception messages (see errors.rb).
|
|
14
|
+
class Key
|
|
15
|
+
# The label of each `-----BEGIN <label>-----` line.
|
|
16
|
+
PEM_LABEL = /^-----BEGIN ([A-Z0-9 ]+)-----\s*$/
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Decides certificate vs key from the PEM block labels, not from
|
|
20
|
+
# substrings anywhere in the text (S-15). A PEM holding both a
|
|
21
|
+
# certificate and a key raises instead of silently keeping one.
|
|
22
|
+
def from_pem(pem, password: nil)
|
|
23
|
+
pem = pem.to_s
|
|
24
|
+
material =
|
|
25
|
+
if certificate_pem?(pem)
|
|
26
|
+
begin
|
|
27
|
+
::OpenSSL::X509::Certificate.new(pem)
|
|
28
|
+
rescue ::OpenSSL::X509::CertificateError => e
|
|
29
|
+
raise HankoXml::KeyLoadError, "could not parse certificate PEM: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
begin
|
|
33
|
+
read_pkey(pem, password)
|
|
34
|
+
rescue ::OpenSSL::PKey::PKeyError => e
|
|
35
|
+
raise HankoXml::KeyLoadError,
|
|
36
|
+
"could not parse key PEM: #{e.message}#{encrypted_hint(pem, password)}#{path_hint(pem)}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
new(material)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# OpenSSL::PKey.read with a nil password falls back to OpenSSL's
|
|
44
|
+
# default callback, which prompts "Enter PEM pass phrase:" on the
|
|
45
|
+
# controlling terminal and blocks the process until someone types.
|
|
46
|
+
# An empty String never prompts: an encrypted key just fails to load.
|
|
47
|
+
def read_pkey(material, password)
|
|
48
|
+
::OpenSSL::PKey.read(material, password.nil? ? "" : password.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def encrypted_hint(pem, password)
|
|
52
|
+
return "" unless password.nil?
|
|
53
|
+
return "" unless pem.include?("ENCRYPTED")
|
|
54
|
+
|
|
55
|
+
". The key is encrypted; pass password: (HankoXml::Key.from_pem(pem, password: ...))"
|
|
56
|
+
end
|
|
57
|
+
private :encrypted_hint
|
|
58
|
+
|
|
59
|
+
# A String is always key material, never a filename (S-21): accepting
|
|
60
|
+
# both would make "which one was meant" a guess. The hint never echoes
|
|
61
|
+
# the input, which may be (malformed) key material.
|
|
62
|
+
def path_hint(pem)
|
|
63
|
+
return "" if pem.include?("-----BEGIN")
|
|
64
|
+
|
|
65
|
+
". A String is read as PEM/DER content, not a file path; to load a file, pass " \
|
|
66
|
+
"Pathname.new(path) or use HankoXml::Key.from_file(path)"
|
|
67
|
+
end
|
|
68
|
+
private :path_hint
|
|
69
|
+
|
|
70
|
+
def certificate_pem?(pem)
|
|
71
|
+
labels = pem.scan(PEM_LABEL).flatten
|
|
72
|
+
certificates = labels.count { |label| label.end_with?("CERTIFICATE") }
|
|
73
|
+
keys = labels.count { |label| label.end_with?("KEY") }
|
|
74
|
+
return false if certificates.zero?
|
|
75
|
+
return true if keys.zero?
|
|
76
|
+
|
|
77
|
+
raise HankoXml::KeyLoadError,
|
|
78
|
+
"PEM contains both a certificate and a key; pass them separately as key: and certificate:"
|
|
79
|
+
end
|
|
80
|
+
private :certificate_pem?
|
|
81
|
+
|
|
82
|
+
def from_der(der)
|
|
83
|
+
material =
|
|
84
|
+
begin
|
|
85
|
+
::OpenSSL::X509::Certificate.new(der)
|
|
86
|
+
rescue ::OpenSSL::X509::CertificateError
|
|
87
|
+
begin
|
|
88
|
+
read_pkey(der, nil)
|
|
89
|
+
rescue ::OpenSSL::PKey::PKeyError => e
|
|
90
|
+
raise HankoXml::KeyLoadError, "could not parse DER key/certificate: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
new(material)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def from_file(path, password: nil)
|
|
98
|
+
from_pem(::File.binread(path.respond_to?(:to_path) ? path.to_path : path), password: password)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def from_pkcs12(path, password:)
|
|
102
|
+
bytes = ::File.binread(path.respond_to?(:to_path) ? path.to_path : path)
|
|
103
|
+
pkcs12 =
|
|
104
|
+
begin
|
|
105
|
+
::OpenSSL::PKCS12.new(bytes, password)
|
|
106
|
+
rescue ::OpenSSL::PKCS12::PKCS12Error => e
|
|
107
|
+
raise HankoXml::KeyLoadError, "could not read PKCS#12 bundle: #{e.message}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
Bundle.new(
|
|
111
|
+
key: new(pkcs12.key),
|
|
112
|
+
certificate: new(pkcs12.certificate),
|
|
113
|
+
certificates: [new(pkcs12.certificate), *Array(pkcs12.ca_certs).map { |c| new(c) }]
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# key: / certificate: / certificates: -- coerces any of the accepted
|
|
119
|
+
# input shapes (OpenSSL object, PEM String, path-like, or an existing
|
|
120
|
+
# Key) into a Key. Returns +input+ unchanged if it is already a Key.
|
|
121
|
+
def self.coerce(input, password: nil)
|
|
122
|
+
case input
|
|
123
|
+
when Key
|
|
124
|
+
input
|
|
125
|
+
when ::OpenSSL::PKey::PKey, ::OpenSSL::X509::Certificate
|
|
126
|
+
new(input)
|
|
127
|
+
when String
|
|
128
|
+
from_pem(input, password: password)
|
|
129
|
+
when ->(obj) { obj.respond_to?(:to_path) }
|
|
130
|
+
from_file(input, password: password)
|
|
131
|
+
else
|
|
132
|
+
raise ArgumentError,
|
|
133
|
+
"expected an OpenSSL::PKey::PKey, OpenSSL::X509::Certificate, PEM String, " \
|
|
134
|
+
"path-like object, or HankoXml::Key, got #{input.class}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def initialize(material)
|
|
139
|
+
@material = material
|
|
140
|
+
@disposed = false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def certificate?
|
|
144
|
+
@material.is_a?(::OpenSSL::X509::Certificate)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# True only for a key that has its private half (S-15). A public-only
|
|
148
|
+
# OpenSSL::PKey::PKey is false.
|
|
149
|
+
def private_key?
|
|
150
|
+
return false unless @material.is_a?(::OpenSSL::PKey::PKey)
|
|
151
|
+
return @material.private? if @material.respond_to?(:private?)
|
|
152
|
+
|
|
153
|
+
# Generic PKey (e.g. Ed25519) has no #private?; only a private key
|
|
154
|
+
# serializes its private half.
|
|
155
|
+
@material.private_to_der
|
|
156
|
+
true
|
|
157
|
+
rescue ::OpenSSL::PKey::PKeyError
|
|
158
|
+
false
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def to_pem
|
|
162
|
+
raise HankoXml::KeyLoadError, "this key has been disposed" if @disposed
|
|
163
|
+
|
|
164
|
+
@material.to_pem
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def to_openssl
|
|
168
|
+
raise HankoXml::KeyLoadError, "this key has been disposed" if @disposed
|
|
169
|
+
|
|
170
|
+
@material
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Drops this Key's reference to its OpenSSL object and makes #to_pem /
|
|
174
|
+
# #to_openssl raise from then on. It does NOT zeroize anything (S-15):
|
|
175
|
+
# the key bytes live in OpenSSL-owned memory freed whenever Ruby's GC
|
|
176
|
+
# collects the object, and every String #to_pem already returned is the
|
|
177
|
+
# caller's, untouched. Use it to stop later accidental use, not as a
|
|
178
|
+
# memory-hygiene guarantee.
|
|
179
|
+
def dispose!
|
|
180
|
+
return if @disposed
|
|
181
|
+
|
|
182
|
+
@disposed = true
|
|
183
|
+
@material = nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def disposed?
|
|
187
|
+
@disposed
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def inspect
|
|
191
|
+
"#<#{self.class} #{certificate? ? "certificate" : "key"}=[FILTERED]>"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def to_s
|
|
195
|
+
inspect
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# key:, certificate:, certificates: bundle returned by .from_pkcs12.
|
|
199
|
+
class Bundle
|
|
200
|
+
attr_reader :key, :certificate, :certificates
|
|
201
|
+
|
|
202
|
+
def initialize(key:, certificate:, certificates:)
|
|
203
|
+
@key = key
|
|
204
|
+
@certificate = certificate
|
|
205
|
+
@certificates = certificates
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def to_h
|
|
209
|
+
{ key: key, certificate: certificate, certificates: certificates }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HankoXml
|
|
4
|
+
# KeyInfo clauses a Signer emits into <ds:KeyInfo>, and the parsed,
|
|
5
|
+
# unauthenticated view a Verification exposes as what the document
|
|
6
|
+
# *claimed*. See API_CONTRACT.md §5.2, §5.4.
|
|
7
|
+
module KeyInfo
|
|
8
|
+
# Recognised clause names, and which <ds:X509Data> child (if any) they
|
|
9
|
+
# map to when building a template.
|
|
10
|
+
CLAUSES = %i[
|
|
11
|
+
x509_certificate
|
|
12
|
+
x509_subject_name
|
|
13
|
+
x509_issuer_serial
|
|
14
|
+
x509_ski
|
|
15
|
+
key_name
|
|
16
|
+
key_value
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# A single clause a Signer will emit, e.g. :x509_certificate. +value+ is
|
|
20
|
+
# the text a clause writes itself rather than leaving to xmlsec: only
|
|
21
|
+
# :key_name has one, the name for <KeyName> (#67).
|
|
22
|
+
Clause = Struct.new(:name, :value) do
|
|
23
|
+
def initialize(name, value = nil)
|
|
24
|
+
name = name.to_sym
|
|
25
|
+
unless KeyInfo::CLAUSES.include?(name)
|
|
26
|
+
raise HankoXml::ConfigurationError,
|
|
27
|
+
"unknown key_info clause #{name.inspect}, expected one of #{KeyInfo::CLAUSES.inspect}"
|
|
28
|
+
end
|
|
29
|
+
if !value.nil? && name != :key_name
|
|
30
|
+
raise HankoXml::ConfigurationError, "key_info clause #{name.inspect} takes no value"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
super(name, value&.to_s&.freeze)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Normalizes a Signer's key_info: value (an Array of Symbols/Clauses,
|
|
40
|
+
# or a single one) into a frozen Array<Clause>. A Hash names a clause
|
|
41
|
+
# with its value, e.g. `{ key_name: "idp-1" }`.
|
|
42
|
+
def coerce(value)
|
|
43
|
+
value = [value] if value.is_a?(Hash) # Array(hash) would split it into pairs
|
|
44
|
+
Array(value).flat_map do |v|
|
|
45
|
+
case v
|
|
46
|
+
when Clause then v
|
|
47
|
+
when Hash then v.map { |name, clause_value| Clause.new(name, clause_value) }
|
|
48
|
+
else Clause.new(v)
|
|
49
|
+
end
|
|
50
|
+
end.freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# What a document's <ds:KeyInfo> actually contained, read out but never
|
|
55
|
+
# authenticated (Verification#key_info, API_CONTRACT.md §5.4): its
|
|
56
|
+
# <KeyName> texts and <X509Certificate>s as PEM Strings.
|
|
57
|
+
class Parsed
|
|
58
|
+
attr_reader :key_names, :certificates
|
|
59
|
+
|
|
60
|
+
def initialize(key_names: [], certificates: [])
|
|
61
|
+
@key_names = key_names.freeze
|
|
62
|
+
@certificates = certificates.freeze
|
|
63
|
+
freeze
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HankoXml
|
|
4
|
+
# Guards the one failure mode a bindgen-built extension cannot detect on its
|
|
5
|
+
# own: being loaded against a libxmlsec1 whose structs are laid out
|
|
6
|
+
# differently from the headers the bindings were generated from.
|
|
7
|
+
#
|
|
8
|
+
# +ext/hanko_xml/build.rs+ runs bindgen at compile time, which bakes xmlsec's
|
|
9
|
+
# and libxml2's struct layouts into the compiled extension. Nothing about a
|
|
10
|
+
# later layout change is visible to the dynamic linker: every symbol still
|
|
11
|
+
# resolves, the library still loads, and then a field is read at the offset
|
|
12
|
+
# our headers claimed rather than the one the loaded library actually used.
|
|
13
|
+
# In a library whose entire job is deciding whether a signature can be
|
|
14
|
+
# trusted, that has to be a hard failure at require time rather than a wrong
|
|
15
|
+
# answer at verify time.
|
|
16
|
+
#
|
|
17
|
+
# For a source-installed gem this check passes by construction -- the machine
|
|
18
|
+
# that compiled the extension is the machine loading it. It earns its keep
|
|
19
|
+
# for precompiled platform gems, and for an extension that outlives an OS
|
|
20
|
+
# package upgrade of libxmlsec1.
|
|
21
|
+
module NativeVersion
|
|
22
|
+
# Downgrades a mismatch from a raise to a warning when set to any non-empty
|
|
23
|
+
# value. This is an escape hatch for someone who has established that their
|
|
24
|
+
# two versions really are compatible; it is not a supported configuration,
|
|
25
|
+
# and a wrong call here is silent memory corruption rather than an error.
|
|
26
|
+
SKIP_ENV = "HANKO_XML_SKIP_VERSION_CHECK"
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Verifies the loaded libxmlsec1 against the compiled-in one.
|
|
30
|
+
#
|
|
31
|
+
# @return [Symbol] +:ok+ when they match exactly, +:abi_compatible+ when
|
|
32
|
+
# they differ only in a way xmlsec considers ABI safe, or +:skipped+
|
|
33
|
+
# when {SKIP_ENV} suppressed the check.
|
|
34
|
+
# @raise [HankoXml::NativeVersionMismatch] when they are not ABI compatible.
|
|
35
|
+
def check!
|
|
36
|
+
return :ok if Native.check_version
|
|
37
|
+
|
|
38
|
+
unless Native.abi_compatible?
|
|
39
|
+
raise NativeVersionMismatch, mismatch_message if skip.nil?
|
|
40
|
+
|
|
41
|
+
warn("hanko_xml: #{mismatch_message}")
|
|
42
|
+
return :skipped
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
:abi_compatible
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The xmlsec1, libxml2, and crypto backend versions this extension was
|
|
49
|
+
# compiled against.
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash{Symbol => String}]
|
|
52
|
+
def build_versions
|
|
53
|
+
Native.versions
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def skip
|
|
59
|
+
value = ENV[SKIP_ENV]
|
|
60
|
+
value unless value.nil? || value.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def mismatch_message
|
|
64
|
+
built = build_versions
|
|
65
|
+
"the loaded libxmlsec1 is not ABI compatible with the one this " \
|
|
66
|
+
"extension was compiled against (xmlsec1 #{built[:xmlsec]}, " \
|
|
67
|
+
"libxml2 #{built[:libxml2]}). Reinstall hanko_xml so the extension " \
|
|
68
|
+
"is rebuilt against the libxmlsec1 currently on this machine " \
|
|
69
|
+
"(`gem pristine hanko_xml`, or `bundle exec rake compile` from a " \
|
|
70
|
+
"checkout). Set #{SKIP_ENV}=1 to downgrade this to a warning if you " \
|
|
71
|
+
"have established the two are compatible."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../hanko_xml"
|
|
4
|
+
|
|
5
|
+
# Core extensions for Nokogiri::XML::Document and Nokogiri::XML::Node to
|
|
6
|
+
# support direct #sign, #sign!, #signature_valid?, #signatures, and #signed?
|
|
7
|
+
# See API_CONTRACT.md §6.6.
|
|
8
|
+
module HankoXml
|
|
9
|
+
# Mixed into Nokogiri::XML::Document and Nokogiri::XML::Node to provide the
|
|
10
|
+
# signing and verification shorthands described in API_CONTRACT.md §6.6.
|
|
11
|
+
module NokogiriExt
|
|
12
|
+
def sign(**kwargs)
|
|
13
|
+
HankoXml.sign(self, **kwargs)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sign!(**kwargs)
|
|
17
|
+
HankoXml.sign!(self, **kwargs)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def signature_valid?(**kwargs)
|
|
21
|
+
HankoXml.valid?(self, **kwargs)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def signatures
|
|
25
|
+
xpath(".//ds:Signature", ds: NS["ds"])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def signature_present?
|
|
29
|
+
HankoXml.signature_present?(self)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
HankoXml::Dom.extend_nodes(HankoXml::NokogiriExt)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HankoXml
|
|
4
|
+
# Where the <Signature> element goes. Frozen value object; Symbols coerce.
|
|
5
|
+
# See API_CONTRACT.md §5.5.
|
|
6
|
+
class Placement
|
|
7
|
+
MODES = %i[append_to_root prepend_to_root append_to prepend_to after before detached custom].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :mode, :target, :namespaces, :block
|
|
10
|
+
|
|
11
|
+
def initialize(mode, target: nil, namespaces: {}, &block)
|
|
12
|
+
mode = mode.to_sym
|
|
13
|
+
raise HankoXml::ConfigurationError, "unknown placement mode #{mode.inspect}" unless MODES.include?(mode)
|
|
14
|
+
|
|
15
|
+
@mode = mode
|
|
16
|
+
@target = target
|
|
17
|
+
@namespaces = namespaces
|
|
18
|
+
@block = block
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def detached?
|
|
23
|
+
mode == :detached
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def append_to_root
|
|
28
|
+
new(:append_to_root)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def prepend_to_root
|
|
32
|
+
new(:prepend_to_root)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def append_to(node_or_xpath, namespaces: {})
|
|
36
|
+
new(:append_to, target: node_or_xpath, namespaces: namespaces)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def prepend_to(node_or_xpath, namespaces: {})
|
|
40
|
+
new(:prepend_to, target: node_or_xpath, namespaces: namespaces)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def after(node_or_xpath, namespaces: {})
|
|
44
|
+
new(:after, target: node_or_xpath, namespaces: namespaces)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def before(node_or_xpath, namespaces: {})
|
|
48
|
+
new(:before, target: node_or_xpath, namespaces: namespaces)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def detached
|
|
52
|
+
new(:detached)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def custom(&block)
|
|
56
|
+
new(:custom, &block)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Coerces a Placement, a recognised Symbol (:append_to_root,
|
|
60
|
+
# :prepend_to_root, :detached), or leaves nil as-is.
|
|
61
|
+
def coerce(value)
|
|
62
|
+
case value
|
|
63
|
+
when Placement then value
|
|
64
|
+
when Symbol then new(value)
|
|
65
|
+
when nil then nil
|
|
66
|
+
else
|
|
67
|
+
raise ArgumentError, "expected a HankoXml::Placement or Symbol, got #{value.class}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Resolves +target+ to exactly one Nokogiri::XML::Node within +doc+.
|
|
73
|
+
# The XPath sees HankoXml::NS, then the Signer's +namespaces:+, then
|
|
74
|
+
# Placement#namespaces, each merged over the last (#96). Raises
|
|
75
|
+
# HankoXml::PlacementError if the XPath matches zero or more than one
|
|
76
|
+
# node.
|
|
77
|
+
def resolve(doc, namespaces: {})
|
|
78
|
+
return doc.root if target.nil?
|
|
79
|
+
return target if HankoXml::Dom.node?(target)
|
|
80
|
+
|
|
81
|
+
ns = HankoXml::NS.merge((namespaces || {}).transform_keys(&:to_s)).merge(self.namespaces.transform_keys(&:to_s))
|
|
82
|
+
matches = HankoXml::Dom.xpath(doc, target, ns) do
|
|
83
|
+
raise HankoXml::PlacementError,
|
|
84
|
+
"placement XPath #{target.inspect} is not valid XPath; bind every prefix it uses in namespaces:"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if matches.empty?
|
|
88
|
+
raise HankoXml::PlacementError, "placement XPath #{target.inspect} matched no nodes"
|
|
89
|
+
elsif matches.size > 1
|
|
90
|
+
raise HankoXml::PlacementError,
|
|
91
|
+
"placement XPath #{target.inspect} matched #{matches.size} nodes, expected exactly one"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
matches.first
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Inserts +signature_node+ into +doc+ per this placement's mode.
|
|
98
|
+
# +namespaces+ are the Signer's, see #resolve. Returns +signature_node+.
|
|
99
|
+
def insert(doc, signature_node, namespaces: {})
|
|
100
|
+
case mode
|
|
101
|
+
when :append_to_root
|
|
102
|
+
doc.root.add_child(signature_node)
|
|
103
|
+
when :prepend_to_root
|
|
104
|
+
if doc.root.children.first
|
|
105
|
+
doc.root.children.first.add_previous_sibling(signature_node)
|
|
106
|
+
else
|
|
107
|
+
doc.root.add_child(signature_node)
|
|
108
|
+
end
|
|
109
|
+
when :append_to
|
|
110
|
+
resolve(doc, namespaces: namespaces).add_child(signature_node)
|
|
111
|
+
when :prepend_to
|
|
112
|
+
node = resolve(doc, namespaces: namespaces)
|
|
113
|
+
node.children.first ? node.children.first.add_previous_sibling(signature_node) : node.add_child(signature_node)
|
|
114
|
+
when :after
|
|
115
|
+
resolve(doc, namespaces: namespaces).add_next_sibling(signature_node)
|
|
116
|
+
when :before
|
|
117
|
+
resolve(doc, namespaces: namespaces).add_previous_sibling(signature_node)
|
|
118
|
+
when :detached
|
|
119
|
+
# Caller does not insert; Signer#template returns the built node.
|
|
120
|
+
when :custom
|
|
121
|
+
block.call(doc, signature_node)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
signature_node
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|