causalontology 2.0.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/LICENSE +203 -0
- data/README.md +73 -0
- data/conformance.rb +1179 -0
- data/lib/causalontology/canonical.rb +100 -0
- data/lib/causalontology/ed25519.rb +158 -0
- data/lib/causalontology/jcs.rb +95 -0
- data/lib/causalontology/schema.rb +204 -0
- data/lib/causalontology/semantics.rb +455 -0
- data/lib/causalontology/signing.rb +74 -0
- data/lib/causalontology/store.rb +358 -0
- data/lib/causalontology.rb +143 -0
- metadata +63 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
# Canonicalization and content-addressed identity.
|
|
4
|
+
#
|
|
5
|
+
# Implements the identity procedure of spec/identity.md:
|
|
6
|
+
# 1. take the object as JSON,
|
|
7
|
+
# 2. keep only the identity-bearing fields for its kind (with "type" injected),
|
|
8
|
+
# 3. serialize with the JSON Canonicalization Scheme (RFC 8785),
|
|
9
|
+
# 4. hash with SHA-256,
|
|
10
|
+
# 5. identifier = scheme + ":" + lowercase hex digest.
|
|
11
|
+
|
|
12
|
+
require "digest"
|
|
13
|
+
require_relative "jcs"
|
|
14
|
+
|
|
15
|
+
module Causalontology
|
|
16
|
+
module Canonical
|
|
17
|
+
# The identity-bearing fields of each of the seventeen kinds. "type" is
|
|
18
|
+
# always injected, so it is not listed here. Order does not matter (JCS
|
|
19
|
+
# sorts keys).
|
|
20
|
+
IDENTITY_FIELDS = {
|
|
21
|
+
# ---- type tier ----
|
|
22
|
+
"occurrent" => ["label", "category", "stratum"],
|
|
23
|
+
"causal_relation_object" => ["causes", "effects", "mechanism", "temporal",
|
|
24
|
+
"modality", "context", "refines", "skips"],
|
|
25
|
+
"continuant" => ["label", "category"],
|
|
26
|
+
"realizable" => ["kind", "bearer", "label"],
|
|
27
|
+
"stratum" => ["label", "scheme", "ordinal", "unit", "governs"],
|
|
28
|
+
"bridge" => ["coarse", "fine", "relation"],
|
|
29
|
+
"port" => ["bearer", "label", "direction", "accepts", "realizable"],
|
|
30
|
+
"conduit" => ["label", "from", "to", "carries", "transform"],
|
|
31
|
+
"quality" => ["label", "datatype", "unit", "stratum"],
|
|
32
|
+
# ---- token tier ----
|
|
33
|
+
"token_individual" => ["instantiates", "designator", "part_of"],
|
|
34
|
+
"token_occurrence" => ["instantiates", "interval", "participants",
|
|
35
|
+
"locus", "observer"],
|
|
36
|
+
"state_assertion" => ["subject", "quality", "value", "interval"],
|
|
37
|
+
"token_causal_claim" => ["causes", "effects", "covering_law",
|
|
38
|
+
"actual_delay", "counterfactual"],
|
|
39
|
+
# ---- provenance tier ----
|
|
40
|
+
"assertion" => ["about", "source", "evidence_type", "evidence", "strength",
|
|
41
|
+
"confidence", "timestamp", "evidenced_by"],
|
|
42
|
+
"enrichment" => ["about", "field", "entry", "source", "timestamp"],
|
|
43
|
+
"retraction" => ["retracts", "source", "timestamp"],
|
|
44
|
+
"succession" => ["predecessor", "successor", "timestamp"],
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Whole-word re-mint (P7): the scheme IS the type value for every kind.
|
|
48
|
+
PREFIX = IDENTITY_FIELDS.keys.each_with_object({}) { |k, h| h[k] = k }.freeze
|
|
49
|
+
KIND_OF_PREFIX = PREFIX.invert.freeze
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Infer an object's kind from its type field, id prefix, or shape.
|
|
54
|
+
def infer_kind(obj)
|
|
55
|
+
return obj["type"] if obj.key?("type")
|
|
56
|
+
if obj.key?("id") && obj["id"].is_a?(String) && obj["id"].include?(":")
|
|
57
|
+
pre = obj["id"].split(":", 2)[0]
|
|
58
|
+
return KIND_OF_PREFIX[pre] if KIND_OF_PREFIX.key?(pre)
|
|
59
|
+
end
|
|
60
|
+
return "bridge" if obj.key?("coarse") && obj.key?("fine")
|
|
61
|
+
return "causal_relation_object" if obj.key?("causes") && obj.key?("effects")
|
|
62
|
+
return "retraction" if obj.key?("retracts")
|
|
63
|
+
return "succession" if obj.key?("predecessor") && obj.key?("successor")
|
|
64
|
+
return "enrichment" if obj.key?("field") && obj.key?("entry")
|
|
65
|
+
return "assertion" if obj.key?("evidence_type") ||
|
|
66
|
+
(obj.key?("about") && obj.key?("confidence"))
|
|
67
|
+
return "realizable" if obj.key?("kind") && obj.key?("bearer")
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
"cannot infer kind (occurrents and continuants share a shape); " \
|
|
70
|
+
"pass kind explicitly"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The identity-bearing subset of an object, with type always present.
|
|
74
|
+
# Returns [kind, subset].
|
|
75
|
+
def identity_bearing(obj, kind = nil)
|
|
76
|
+
kind ||= infer_kind(obj)
|
|
77
|
+
unless IDENTITY_FIELDS.key?(kind)
|
|
78
|
+
raise ArgumentError, "unknown kind: #{kind.inspect}"
|
|
79
|
+
end
|
|
80
|
+
out = { "type" => kind }
|
|
81
|
+
IDENTITY_FIELDS[kind].each do |field|
|
|
82
|
+
out[field] = obj[field] if obj.key?(field)
|
|
83
|
+
end
|
|
84
|
+
[kind, out]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The RFC 8785 identity-bearing bytes of an object (a UTF-8 string).
|
|
88
|
+
def canonicalize(obj, kind = nil)
|
|
89
|
+
_, ib = identity_bearing(obj, kind)
|
|
90
|
+
Jcs.encode(ib).encode(Encoding::UTF_8)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The content-addressed identifier: scheme + ":" + SHA-256 hex.
|
|
94
|
+
def identify(obj, kind = nil)
|
|
95
|
+
kind, ib = identity_bearing(obj, kind)
|
|
96
|
+
digest = Digest::SHA256.hexdigest(Jcs.encode(ib).encode(Encoding::UTF_8))
|
|
97
|
+
PREFIX[kind] + ":" + digest
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
# Ed25519 digital signatures (RFC 8032), pure Ruby, standard library only.
|
|
4
|
+
#
|
|
5
|
+
# Slow but correct: intended for the conformance suite and for small tools.
|
|
6
|
+
# Production stores should use an optimized library; the signatures are
|
|
7
|
+
# byte-compatible either way (Ed25519 is deterministic, RFC 8032).
|
|
8
|
+
#
|
|
9
|
+
# Ruby's native bignums make this a direct port of the Python reference:
|
|
10
|
+
# Integer#pow(exp, mod) is Python's three-argument pow, and Ruby's % on a
|
|
11
|
+
# positive modulus is floored (always non-negative), exactly like Python's.
|
|
12
|
+
# All byte strings handled here are forced to ASCII-8BIT (binary), so
|
|
13
|
+
# byteslice/reverse operate on bytes, never on multibyte characters.
|
|
14
|
+
|
|
15
|
+
require "digest"
|
|
16
|
+
|
|
17
|
+
module Causalontology
|
|
18
|
+
module Ed25519
|
|
19
|
+
P = 2**255 - 19
|
|
20
|
+
Q = 2**252 + 27742317777372353535851937790883648493
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def sha512(s)
|
|
25
|
+
Digest::SHA512.digest(s)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def modp_inv(x)
|
|
29
|
+
x.pow(P - 2, P)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A 32-byte little-endian string for a non-negative integer below 2**256.
|
|
33
|
+
def int_to_le32(n)
|
|
34
|
+
hex = n.to_s(16).rjust(64, "0")
|
|
35
|
+
[hex].pack("H*").reverse
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The non-negative integer encoded by a little-endian byte string.
|
|
39
|
+
def le_to_int(s)
|
|
40
|
+
s.dup.force_encoding(Encoding::BINARY).reverse.unpack1("H*").to_i(16)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The curve constant d = -121665 / 121666 (mod p).
|
|
44
|
+
CURVE_D = -121665 * modp_inv(121666) % P
|
|
45
|
+
# A square root of -1 (mod p), used in point decompression.
|
|
46
|
+
SQRT_M1 = 2.pow((P - 1) / 4, P)
|
|
47
|
+
|
|
48
|
+
# Points are [x, y, z, t] in extended homogeneous coordinates.
|
|
49
|
+
def point_add(pt, qt)
|
|
50
|
+
a = (pt[1] - pt[0]) * (qt[1] - qt[0]) % P
|
|
51
|
+
b = (pt[1] + pt[0]) * (qt[1] + qt[0]) % P
|
|
52
|
+
c = 2 * pt[3] * qt[3] * CURVE_D % P
|
|
53
|
+
d = 2 * pt[2] * qt[2] % P
|
|
54
|
+
e = b - a
|
|
55
|
+
f = d - c
|
|
56
|
+
g = d + c
|
|
57
|
+
h = b + a
|
|
58
|
+
[e * f % P, g * h % P, f * g % P, e * h % P]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def point_mul(s, pt)
|
|
62
|
+
q = [0, 1, 1, 0] # the neutral element
|
|
63
|
+
while s > 0
|
|
64
|
+
q = point_add(q, pt) if s & 1 == 1
|
|
65
|
+
pt = point_add(pt, pt)
|
|
66
|
+
s >>= 1
|
|
67
|
+
end
|
|
68
|
+
q
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def point_equal(pt, qt)
|
|
72
|
+
return false if (pt[0] * qt[2] - qt[0] * pt[2]) % P != 0
|
|
73
|
+
return false if (pt[1] * qt[2] - qt[1] * pt[2]) % P != 0
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def recover_x(y, sign)
|
|
78
|
+
return nil if y >= P
|
|
79
|
+
x2 = (y * y - 1) * modp_inv(CURVE_D * y * y + 1) % P
|
|
80
|
+
return (sign == 1 ? nil : 0) if x2 == 0
|
|
81
|
+
x = x2.pow((P + 3) / 8, P)
|
|
82
|
+
x = x * SQRT_M1 % P if (x * x - x2) % P != 0
|
|
83
|
+
return nil if (x * x - x2) % P != 0
|
|
84
|
+
x = P - x if (x & 1) != sign
|
|
85
|
+
x
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The base point G.
|
|
89
|
+
BASE_Y = 4 * modp_inv(5) % P
|
|
90
|
+
BASE_X = recover_x(BASE_Y, 0)
|
|
91
|
+
BASE_POINT = [BASE_X, BASE_Y, 1, BASE_X * BASE_Y % P].freeze
|
|
92
|
+
|
|
93
|
+
def point_compress(pt)
|
|
94
|
+
zinv = modp_inv(pt[2])
|
|
95
|
+
x = pt[0] * zinv % P
|
|
96
|
+
y = pt[1] * zinv % P
|
|
97
|
+
int_to_le32(y | ((x & 1) << 255))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def point_decompress(s)
|
|
101
|
+
return nil if s.bytesize != 32
|
|
102
|
+
y = le_to_int(s)
|
|
103
|
+
sign = y >> 255
|
|
104
|
+
y &= (1 << 255) - 1
|
|
105
|
+
x = recover_x(y, sign)
|
|
106
|
+
return nil if x.nil?
|
|
107
|
+
[x, y, 1, x * y % P]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def secret_expand(secret)
|
|
111
|
+
raise ArgumentError, "secret key must be 32 bytes" if secret.bytesize != 32
|
|
112
|
+
h = sha512(secret)
|
|
113
|
+
a = le_to_int(h.byteslice(0, 32))
|
|
114
|
+
a &= (1 << 254) - 8
|
|
115
|
+
a |= (1 << 254)
|
|
116
|
+
[a, h.byteslice(32, 32)]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def sha512_modq(s)
|
|
120
|
+
le_to_int(sha512(s)) % Q
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The 32-byte public key for a 32-byte secret key.
|
|
124
|
+
def secret_to_public(secret)
|
|
125
|
+
a, _prefix = secret_expand(secret)
|
|
126
|
+
point_compress(point_mul(a, BASE_POINT))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The 64-byte Ed25519 signature of msg under the 32-byte secret key.
|
|
130
|
+
def sign(secret, msg)
|
|
131
|
+
msg = msg.dup.force_encoding(Encoding::BINARY)
|
|
132
|
+
a, prefix = secret_expand(secret)
|
|
133
|
+
public_key = point_compress(point_mul(a, BASE_POINT))
|
|
134
|
+
r = sha512_modq(prefix + msg)
|
|
135
|
+
rs = point_compress(point_mul(r, BASE_POINT))
|
|
136
|
+
h = sha512_modq(rs + public_key + msg)
|
|
137
|
+
s = (r + h * a) % Q
|
|
138
|
+
rs + int_to_le32(s)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# True iff signature is a valid Ed25519 signature of msg under public.
|
|
142
|
+
def verify(public_key, msg, signature)
|
|
143
|
+
msg = msg.dup.force_encoding(Encoding::BINARY)
|
|
144
|
+
return false if public_key.bytesize != 32 || signature.bytesize != 64
|
|
145
|
+
a_point = point_decompress(public_key)
|
|
146
|
+
return false if a_point.nil?
|
|
147
|
+
rs = signature.byteslice(0, 32)
|
|
148
|
+
r_point = point_decompress(rs)
|
|
149
|
+
return false if r_point.nil?
|
|
150
|
+
s = le_to_int(signature.byteslice(32, 32))
|
|
151
|
+
return false if s >= Q
|
|
152
|
+
h = sha512_modq(rs + public_key + msg)
|
|
153
|
+
sb = point_mul(s, BASE_POINT)
|
|
154
|
+
ha = point_mul(h, a_point)
|
|
155
|
+
point_equal(sb, point_add(r_point, ha))
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
# RFC 8785 (JSON Canonicalization Scheme) serialization.
|
|
4
|
+
#
|
|
5
|
+
# Sorted keys (code-point order), minimal string escaping, ECMAScript-style
|
|
6
|
+
# canonical numbers (1.0 -> "1", 0.7 stays "0.7", exponent "1e-7" not
|
|
7
|
+
# "1e-07"). The number serialization implements the RFC 8785 rules for the
|
|
8
|
+
# value ranges Causalontology uses (integers, integer-valued floats, and
|
|
9
|
+
# short decimals); full ECMAScript exponent formatting for extreme
|
|
10
|
+
# magnitudes is pinned at the 1.0.0 conformance freeze.
|
|
11
|
+
#
|
|
12
|
+
# Ruby specifics relied on here: JSON.parse keeps the integer-versus-decimal
|
|
13
|
+
# source distinction ("1" -> Integer, "1.0" -> Float), and Float#to_s prints
|
|
14
|
+
# the shortest round-trip decimal with the same decimal/exponent thresholds
|
|
15
|
+
# as Python's repr (1e16 and 1e-4), so only the exponent spelling needs
|
|
16
|
+
# normalizing ("1.0e-07" -> "1e-7").
|
|
17
|
+
|
|
18
|
+
module Causalontology
|
|
19
|
+
module Jcs
|
|
20
|
+
# The two-character escapes of RFC 8785 section 3.2.2.2.
|
|
21
|
+
ESCAPES = {
|
|
22
|
+
'"' => '\"',
|
|
23
|
+
"\\" => "\\\\",
|
|
24
|
+
"\b" => "\\b",
|
|
25
|
+
"\t" => "\\t",
|
|
26
|
+
"\n" => "\\n",
|
|
27
|
+
"\f" => "\\f",
|
|
28
|
+
"\r" => "\\r",
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# A JSON string literal with minimal escaping: the seven two-character
|
|
34
|
+
# escapes, \u00xx for remaining control characters, everything else
|
|
35
|
+
# (including multibyte text) passed through as UTF-8.
|
|
36
|
+
def string(s)
|
|
37
|
+
parts = +'"'
|
|
38
|
+
s.each_char do |ch|
|
|
39
|
+
if ESCAPES.key?(ch)
|
|
40
|
+
parts << ESCAPES[ch]
|
|
41
|
+
elsif ch.ord < 0x20
|
|
42
|
+
parts << format("\\u%04x", ch.ord)
|
|
43
|
+
else
|
|
44
|
+
parts << ch
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
parts << '"'
|
|
48
|
+
parts
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A canonical JSON number: integers verbatim; integer-valued floats
|
|
52
|
+
# below 1e21 printed as integers; other floats in shortest round-trip
|
|
53
|
+
# form with the exponent normalized to ES6 style ("1e-7", "1e+21").
|
|
54
|
+
def number(n)
|
|
55
|
+
return n.to_s if n.is_a?(Integer)
|
|
56
|
+
raise ArgumentError, "NaN and Infinity are not permitted (RFC 8785)" unless n.finite?
|
|
57
|
+
return "0" if n == 0
|
|
58
|
+
return n.to_i.to_s if n == n.truncate && n.abs < 1e21
|
|
59
|
+
r = n.to_s # shortest round-trip decimal
|
|
60
|
+
if r.include?("e")
|
|
61
|
+
mant, exp = r.split("e")
|
|
62
|
+
mant = mant.sub(/\.0\z/, "") # Ruby prints "1.0e-07"; ES6 prints "1e-7"
|
|
63
|
+
sign = exp.start_with?("-") ? "-" : "+"
|
|
64
|
+
digits = exp.sub(/\A[+-]/, "").sub(/\A0+/, "")
|
|
65
|
+
digits = "0" if digits.empty?
|
|
66
|
+
r = mant + "e" + sign + digits
|
|
67
|
+
end
|
|
68
|
+
r
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The canonical serialization of a parsed JSON value (nil, true/false,
|
|
72
|
+
# Integer, Float, String, Array, or Hash with String keys).
|
|
73
|
+
def encode(value)
|
|
74
|
+
case value
|
|
75
|
+
when nil
|
|
76
|
+
"null"
|
|
77
|
+
when true
|
|
78
|
+
"true"
|
|
79
|
+
when false
|
|
80
|
+
"false"
|
|
81
|
+
when Integer, Float
|
|
82
|
+
number(value)
|
|
83
|
+
when String
|
|
84
|
+
string(value)
|
|
85
|
+
when Array
|
|
86
|
+
"[" + value.map { |v| encode(v) }.join(",") + "]"
|
|
87
|
+
when Hash
|
|
88
|
+
items = value.keys.sort_by(&:codepoints)
|
|
89
|
+
"{" + items.map { |k| string(k) + ":" + encode(value[k]) }.join(",") + "}"
|
|
90
|
+
else
|
|
91
|
+
raise TypeError, "cannot canonicalize #{value.class}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
# Schema validation against spec/schema/*.schema.json.
|
|
4
|
+
#
|
|
5
|
+
# A deliberately small interpreter for exactly the JSON Schema keywords the
|
|
6
|
+
# seventeen Causalontology schemas use: type, const, enum, pattern, required,
|
|
7
|
+
# properties, additionalProperties, items, minItems, minLength, minimum,
|
|
8
|
+
# maximum, oneOf, local $ref (#/$defs/...), and cross-file $ref to a sibling
|
|
9
|
+
# schema (https://causalontology.org/schema/<file>.schema.json#/...). "format"
|
|
10
|
+
# is treated as an annotation, as the 2020-12 draft does by default.
|
|
11
|
+
|
|
12
|
+
require "json"
|
|
13
|
+
require_relative "canonical"
|
|
14
|
+
|
|
15
|
+
module Causalontology
|
|
16
|
+
module Schema
|
|
17
|
+
# kind -> schema file. Three token kinds keep their original 1.0.0-reserved
|
|
18
|
+
# file names (individual/token/state); the id scheme is the whole word.
|
|
19
|
+
SCHEMA_FILES = {
|
|
20
|
+
"occurrent" => "occurrent.schema.json",
|
|
21
|
+
"causal_relation_object" => "causal_relation_object.schema.json",
|
|
22
|
+
"continuant" => "continuant.schema.json",
|
|
23
|
+
"realizable" => "realizable.schema.json",
|
|
24
|
+
"stratum" => "stratum.schema.json",
|
|
25
|
+
"bridge" => "bridge.schema.json",
|
|
26
|
+
"port" => "port.schema.json",
|
|
27
|
+
"conduit" => "conduit.schema.json",
|
|
28
|
+
"quality" => "quality.schema.json",
|
|
29
|
+
"token_individual" => "individual.schema.json",
|
|
30
|
+
"token_occurrence" => "token.schema.json",
|
|
31
|
+
"state_assertion" => "state.schema.json",
|
|
32
|
+
"token_causal_claim" => "token_causal_claim.schema.json",
|
|
33
|
+
"assertion" => "assertion.schema.json",
|
|
34
|
+
"enrichment" => "enrichment.schema.json",
|
|
35
|
+
"retraction" => "retraction.schema.json",
|
|
36
|
+
"succession" => "succession.schema.json",
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
BASE = "https://causalontology.org/schema/"
|
|
40
|
+
|
|
41
|
+
@cache = {}
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def schema_dir
|
|
45
|
+
env = ENV["CAUSALONTOLOGY_SPEC"]
|
|
46
|
+
return File.join(env, "schema") if env && !env.empty?
|
|
47
|
+
# lib/causalontology -> lib -> ruby -> bindings -> repository root
|
|
48
|
+
File.expand_path("../../../../spec/schema", __dir__)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Load and cache a schema document by its file name.
|
|
52
|
+
def load_file(filename)
|
|
53
|
+
@cache[filename] ||= JSON.parse(
|
|
54
|
+
File.read(File.join(schema_dir, filename)))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_schema(kind)
|
|
58
|
+
unless SCHEMA_FILES.key?(kind)
|
|
59
|
+
raise ArgumentError, "unknown kind: #{kind.inspect}"
|
|
60
|
+
end
|
|
61
|
+
load_file(SCHEMA_FILES[kind])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Navigate a JSON pointer (slash-separated) within a document.
|
|
65
|
+
def navigate(doc, pointer)
|
|
66
|
+
node = doc
|
|
67
|
+
pointer.split("/").each do |part|
|
|
68
|
+
next if part == ""
|
|
69
|
+
node = node[part]
|
|
70
|
+
end
|
|
71
|
+
node
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Resolve local and cross-file $refs to a concrete schema node plus the
|
|
75
|
+
# root document it lives in. Returns [schema, root].
|
|
76
|
+
def resolve_ref(schema, root)
|
|
77
|
+
while schema.is_a?(Hash) && schema.key?("$ref")
|
|
78
|
+
ref = schema["$ref"]
|
|
79
|
+
if ref.start_with?("#/")
|
|
80
|
+
schema = navigate(root, ref[2..])
|
|
81
|
+
elsif ref.start_with?(BASE)
|
|
82
|
+
rest = ref[BASE.length..]
|
|
83
|
+
filename, pointer = rest.split("#/", 2)
|
|
84
|
+
root = load_file(filename)
|
|
85
|
+
schema = pointer ? navigate(root, pointer) : root
|
|
86
|
+
else
|
|
87
|
+
raise ArgumentError, "unsupported $ref: #{ref.inspect}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
[schema, root]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def type_matches?(value, t)
|
|
94
|
+
case t
|
|
95
|
+
when "object" then value.is_a?(Hash)
|
|
96
|
+
when "array" then value.is_a?(Array)
|
|
97
|
+
when "string" then value.is_a?(String)
|
|
98
|
+
when "number" then value.is_a?(Integer) || value.is_a?(Float)
|
|
99
|
+
when "integer" then value.is_a?(Integer)
|
|
100
|
+
when "boolean" then value == true || value == false
|
|
101
|
+
else
|
|
102
|
+
raise ArgumentError, "unknown schema type: #{t.inspect}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def numeric?(value)
|
|
107
|
+
value.is_a?(Integer) || value.is_a?(Float)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def check(value, schema, root, path, errors)
|
|
111
|
+
schema, root = resolve_ref(schema, root)
|
|
112
|
+
|
|
113
|
+
if schema.key?("oneOf")
|
|
114
|
+
passing = 0
|
|
115
|
+
schema["oneOf"].each do |sub|
|
|
116
|
+
suberrs = []
|
|
117
|
+
check(value, sub, root, path, suberrs)
|
|
118
|
+
passing += 1 if suberrs.empty?
|
|
119
|
+
end
|
|
120
|
+
if passing != 1
|
|
121
|
+
errors << "#{path}: matches #{passing} of the oneOf branches " \
|
|
122
|
+
"(need exactly 1)"
|
|
123
|
+
end
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
t = schema["type"]
|
|
128
|
+
if !t.nil? && !type_matches?(value, t)
|
|
129
|
+
errors << "#{path}: expected #{t}"
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if schema.key?("const") && value != schema["const"]
|
|
134
|
+
errors << "#{path}: must equal #{schema["const"].inspect}"
|
|
135
|
+
end
|
|
136
|
+
if schema.key?("enum") && !schema["enum"].include?(value)
|
|
137
|
+
errors << "#{path}: #{value.inspect} not in enumeration"
|
|
138
|
+
end
|
|
139
|
+
if schema.key?("pattern") && value.is_a?(String)
|
|
140
|
+
unless Regexp.new(schema["pattern"]).match?(value)
|
|
141
|
+
errors << "#{path}: #{value.inspect} does not match " \
|
|
142
|
+
"#{schema["pattern"]}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
if schema.key?("minLength") && value.is_a?(String)
|
|
146
|
+
if value.length < schema["minLength"]
|
|
147
|
+
errors << "#{path}: shorter than minLength"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
if schema.key?("minimum") && numeric?(value)
|
|
151
|
+
if value < schema["minimum"]
|
|
152
|
+
errors << "#{path}: below minimum #{schema["minimum"]}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
if schema.key?("maximum") && numeric?(value)
|
|
156
|
+
if value > schema["maximum"]
|
|
157
|
+
errors << "#{path}: above maximum #{schema["maximum"]}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if value.is_a?(Array)
|
|
162
|
+
if schema.key?("minItems") && value.length < schema["minItems"]
|
|
163
|
+
errors << "#{path}: fewer than #{schema["minItems"]} items"
|
|
164
|
+
end
|
|
165
|
+
if schema.key?("items")
|
|
166
|
+
value.each_with_index do |item, i|
|
|
167
|
+
check(item, schema["items"], root, "#{path}[#{i}]", errors)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if value.is_a?(Hash)
|
|
173
|
+
props = schema["properties"] || {}
|
|
174
|
+
(schema["required"] || []).each do |req|
|
|
175
|
+
unless value.key?(req)
|
|
176
|
+
errors << "#{path}: required property '#{req}' missing"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
if schema["additionalProperties"] == false
|
|
180
|
+
value.each_key do |key|
|
|
181
|
+
unless props.key?(key)
|
|
182
|
+
errors << "#{path}: additional property '#{key}'"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
props.each do |key, sub|
|
|
187
|
+
if value.key?(key)
|
|
188
|
+
check(value[key], sub, root, "#{path}.#{key}", errors)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# [ok, reasons] - structural validity against the kind's JSON Schema.
|
|
195
|
+
def validate_schema(obj, kind = nil)
|
|
196
|
+
kind ||= Canonical.infer_kind(obj)
|
|
197
|
+
root = load_schema(kind)
|
|
198
|
+
errors = []
|
|
199
|
+
check(obj, root, root, "$", errors)
|
|
200
|
+
[errors.empty?, errors]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|