unidpp 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.
data/lib/unidpp/gs1.rb ADDED
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Unidpp
6
+ # GS1 key arithmetic shared by the identifier parsers: application
7
+ # identifiers, element-string parsing (parenthesized and bracketed
8
+ # human-readable forms) and the GS1 mod-10 check digit (GTIN-13/GTIN-14,
9
+ # SSCC-18).
10
+ module Gs1
11
+ # Identity-relevant application identifiers (AI -> value shape).
12
+ # "00" SSCC (18 digits) "01" GTIN-14
13
+ # "10" batch/lot (<= 20 GS1 AI charset) "21" serial (<= 20 charset)
14
+ IDENTITY_AIS = {
15
+ "00" => /\A\d{18}\z/,
16
+ "01" => /\A\d{14}\z/,
17
+ "10" => /\A[\x21-\x22\x25-\x2F\x30-\x39\x3A-\x3F\x41-\x5A\x5F\x61-\x7A]{1,20}\z/,
18
+ "21" => /\A[\x21-\x22\x25-\x2F\x30-\x39\x3A-\x3F\x41-\x5A\x5F\x61-\x7A]{1,20}\z/,
19
+ }.freeze
20
+
21
+ # Application identifiers accepted in a Digital Link URI path.
22
+ DL_PATH_AIS = %w[00 01 10 21].freeze
23
+
24
+ module_function
25
+
26
+ # GS1 general specification check digit: from the rightmost digit of
27
+ # the base (excluding the check position), multiply alternately by 3
28
+ # and 1; the check digit completes the next multiple of ten.
29
+ #
30
+ # @param base [String, #to_s] digits without the check digit
31
+ # @return [Integer] the check digit
32
+ def check_digit(base)
33
+ digits = base.to_s.chars.map(&:to_i)
34
+ raise Error, "check digit base must be non-empty digits" if digits.empty?
35
+
36
+ sum = digits.reverse.each_with_index.sum do |d, i|
37
+ d * (i.even? ? 3 : 1)
38
+ end
39
+ (10 - (sum % 10)) % 10
40
+ end
41
+
42
+ # @param with_check [String] full code including trailing check digit
43
+ # @return [Boolean]
44
+ def valid_check_digit?(with_check)
45
+ s = with_check.to_s
46
+ return false unless /\A\d+\z/.match?(s)
47
+
48
+ check_digit(s[0...-1]) == s[-1].to_i
49
+ end
50
+
51
+ # Parses a human-readable element string, e.g.
52
+ # (01)09506000134352(10)ABC-123(21)9876543
53
+ # or the bracketed form
54
+ # [00]006141411234567890
55
+ #
56
+ # @param input [String]
57
+ # @return [Hash{String => String}] AI -> value, in encounter order
58
+ # @raise [Unidpp::Error] on malformed input
59
+ def parse_element_string(input)
60
+ opener = input.to_s.start_with?("[") ? "[" : "("
61
+ values = {}
62
+ scanner = StringScanner.new(input.to_s.strip)
63
+ until scanner.eos?
64
+ scanner.skip(/\s*/)
65
+ break if scanner.eos?
66
+
67
+ unless scanner.scan(/#{Regexp.escape(opener)}/)
68
+ raise Error, "malformed element string at #{scanner.pos}: #{input}"
69
+ end
70
+
71
+ ai = scanner.scan(/\d{2,4}/)
72
+ raise Error, "missing application identifier in #{input}" if ai.nil?
73
+
74
+ scanner.skip(/\]/) if opener == "["
75
+ scanner.skip(/\)/) if opener == "("
76
+ # value runs until the next opener or end of string
77
+ value = scanner.scan_until(/(?=#{Regexp.escape(opener)}|\z)/) || scanner.rest
78
+ value = value.sub(/\s+\z/, "")
79
+ raise Error, "empty value for AI (#{ai})" if value.empty?
80
+
81
+ values[ai] = value
82
+ end
83
+ values
84
+ end
85
+
86
+ # Validates AI values against the identity AI table, including the
87
+ # mod-10 check digits of SSCC (00) and GTIN (01).
88
+ #
89
+ # @param values [Hash{String => String}]
90
+ # @raise [Unidpp::Error] on an unsupported AI, bad shape or bad check
91
+ # digit
92
+ def validate_identity_ais!(values)
93
+ values.each do |ai, value|
94
+ pattern = IDENTITY_AIS[ai]
95
+ raise Error, "unsupported identity AI (#{ai})" if pattern.nil?
96
+ raise Error, "invalid value for AI (#{ai}): #{value}" unless pattern.match?(value)
97
+ end
98
+ %w[00 01].each do |ai|
99
+ next unless (code = values[ai])
100
+
101
+ raise Error, "invalid check digit for AI (#{ai}): #{code}" unless valid_check_digit?(code)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unidpp
4
+ # Scheme-agnostic product identifier (framework layer L0, invariant I1:
5
+ # one subject, one identity, never re-minted).
6
+ #
7
+ # Parses the identity carriers the framework must bridge:
8
+ # * GS1 Digital Link URIs — https://id.example.com/01/09506000134352/10/LOT/21/SER
9
+ # * GS1 human-readable element strings — (01)09506000134352(10)LOT(21)SER
10
+ # * ISO/IEC 15459-style unique identifiers — [00]006141411234567890 (SSCC)
11
+ # * GB/T 33993-style national carrier wrappers — an https URL whose path
12
+ # embeds a GTIN-13/GTIN-14, with optional lot/serial query parameters
13
+ # * plain URNs, including urn:epc:id:sgtin/sscc forms
14
+ #
15
+ # The parsed model normalizes the GS1 components (gtin, lot, serial,
16
+ # sscc) and derives the identifier granularity (model / batch / item per
17
+ # the identity lattice: bare GTIN -> model, +lot -> batch, +serial or
18
+ # SSCC -> item).
19
+ #
20
+ # (De)serialization is generated by lutaml-model from the attribute +
21
+ # mapping declarations below — no hand-rolled to_h / from_h.
22
+ class ProductIdentifier < Lutaml::Model::Serializable
23
+ class ParseError < Unidpp::Error; end
24
+
25
+ SCHEMES = %w[gs1-dl gs1-element iso-15459 gbt-33993 urn other].freeze
26
+ GRANULARITIES = %w[model batch item].freeze
27
+
28
+ attribute :scheme, :string, values: SCHEMES, default: -> { "other" }
29
+ # Original carrier value exactly as encountered.
30
+ attribute :value, :string
31
+ attribute :granularity, :string, values: GRANULARITIES,
32
+ default: -> { "model" }
33
+ # Normalized GS1 components (nil when absent).
34
+ attribute :gtin, :string # canonical GTIN-14
35
+ attribute :lot, :string # AI (10)
36
+ attribute :serial, :string # AI (21)
37
+ attribute :sscc, :string # SSCC-18, ISO/IEC 15459 transport unit
38
+
39
+ json do
40
+ map "scheme", to: :scheme
41
+ map "value", to: :value
42
+ map "granularity", to: :granularity
43
+ map "gtin", to: :gtin, render_nil: false
44
+ map "lot", to: :lot, render_nil: false
45
+ map "serial", to: :serial, render_nil: false
46
+ map "sscc", to: :sscc, render_nil: false
47
+ end
48
+
49
+ yaml do
50
+ map "scheme", to: :scheme
51
+ map "value", to: :value
52
+ map "granularity", to: :granularity
53
+ map "gtin", to: :gtin, render_nil: false
54
+ map "lot", to: :lot, render_nil: false
55
+ map "serial", to: :serial, render_nil: false
56
+ map "sscc", to: :sscc, render_nil: false
57
+ end
58
+
59
+ # -- parsing ------------------------------------------------------------
60
+
61
+ # @param input [String] carrier value
62
+ # @return [ProductIdentifier]
63
+ # @raise [ParseError] when no parser claims the input
64
+ def self.parse(input)
65
+ s = input.to_s.strip
66
+ raise ParseError, "blank identifier" if s.empty?
67
+
68
+ if s.match?(/\Aurn:/i)
69
+ parse_urn(s)
70
+ elsif s.match?(/\Ahttps?:\/\//i)
71
+ parse_web_uri(s)
72
+ elsif s.match?(/\A[\(\[]\d{2,4}[\)\]]/)
73
+ parse_element_string(s)
74
+ else
75
+ raise ParseError, "unrecognized identifier syntax: #{s}"
76
+ end
77
+ end
78
+
79
+ # @param input [String] parenthesized / bracketed element string
80
+ def self.parse_element_string(input)
81
+ values = Gs1.parse_element_string(input)
82
+ Gs1.validate_identity_ais!(values)
83
+ if values["00"]
84
+ new(scheme: "iso-15459", value: input, sscc: values["00"],
85
+ granularity: "item")
86
+ elsif values["01"]
87
+ new(scheme: "gs1-element", value: input, gtin: values["01"],
88
+ lot: values["10"], serial: values["21"],
89
+ granularity: granularity_for(values["21"], values["10"]))
90
+ else
91
+ raise ParseError, "no identity AI (00/01) in #{input}"
92
+ end
93
+ end
94
+
95
+ # @param input [String] GS1 Digital Link or GB/T 33993-style URI
96
+ def self.parse_web_uri(input)
97
+ uri = URI.parse(input)
98
+ segments = uri.path.to_s.split("/").reject(&:empty?)
99
+ if dl_identity_pairs?(segments)
100
+ values = segments.each_slice(2).to_h
101
+ Gs1.validate_identity_ais!(values)
102
+ if values["00"]
103
+ new(scheme: "gs1-dl", value: input, sscc: values["00"],
104
+ granularity: "item")
105
+ else
106
+ new(scheme: "gs1-dl", value: input, gtin: values["01"],
107
+ lot: values["10"], serial: values["21"],
108
+ granularity: granularity_for(values["21"], values["10"]))
109
+ end
110
+ elsif (gtin = gbt33993_gtin(segments))
111
+ params = URI.decode_www_form(uri.query.to_s).to_h
112
+ new(scheme: "gbt-33993", value: input, gtin: gtin,
113
+ lot: params["lot"], serial: params["serial"],
114
+ granularity: granularity_for(params["serial"], params["lot"]))
115
+ else
116
+ raise ParseError, "unrecognized identifier URI: #{input}"
117
+ end
118
+ rescue URI::InvalidURIError => e
119
+ raise ParseError, "invalid identifier URI: #{e.message}"
120
+ end
121
+
122
+ # @param input [String] plain or EPC URN
123
+ def self.parse_urn(input)
124
+ urn = input.downcase
125
+ if urn.start_with?("urn:epc:id:sgtin:")
126
+ parse_sgtin_urn(input)
127
+ elsif urn.start_with?("urn:epc:id:sscc:")
128
+ parse_sscc_urn(input)
129
+ else
130
+ new(scheme: "urn", value: input)
131
+ end
132
+ end
133
+
134
+ # urn:epc:id:sgtin:<companyPrefix>.<itemRef>.<serial>
135
+ # prefix+itemRef total 12 digits (GTIN-13 base) or 13 digits (GTIN-14
136
+ # base with leading indicator digit).
137
+ def self.parse_sgtin_urn(input)
138
+ parts = input.split(":").last.split(".")
139
+ raise ParseError, "malformed sgtin URN: #{input}" unless parts.size == 3
140
+
141
+ prefix, item_ref, serial = parts
142
+ base = "#{prefix}#{item_ref}"
143
+ unless /\A\d{12,13}\z/.match?(base)
144
+ raise ParseError,
145
+ "sgtin company prefix + item ref must total 12 or 13 digits"
146
+ end
147
+
148
+ gtin14 = base.length == 13 ? base : "0#{base}"
149
+ new(scheme: "urn", value: input,
150
+ gtin: "#{gtin14}#{Gs1.check_digit(base)}",
151
+ serial: serial, granularity: "item")
152
+ end
153
+
154
+ # urn:epc:id:sscc:<companyPrefix>.<ssccDigits>
155
+ def self.parse_sscc_urn(input)
156
+ parts = input.split(":").last.split(".")
157
+ raise ParseError, "malformed sscc URN: #{input}" unless parts.size == 2
158
+
159
+ joined = parts.join
160
+ sscc =
161
+ if joined.length == 17
162
+ "#{joined}#{Gs1.check_digit(joined)}"
163
+ elsif joined.length == 18
164
+ joined
165
+ else
166
+ raise ParseError, "sscc URN must carry 17 or 18 digits: #{input}"
167
+ end
168
+ raise ParseError, "invalid SSCC check digit in #{input}" unless Gs1.valid_check_digit?(sscc)
169
+
170
+ new(scheme: "urn", value: input, sscc: sscc, granularity: "item")
171
+ end
172
+
173
+ # -- derived identity ----------------------------------------------------
174
+
175
+ # Canonical human-readable form. GS1-based schemes canonicalize to the
176
+ # element string in ascending AI order, so the same product expressed
177
+ # as Digital Link or element string yields the same canonical form.
178
+ #
179
+ # @return [String]
180
+ def canonical
181
+ case scheme
182
+ when "gs1-dl", "gs1-element"
183
+ %w[01 10 21].filter_map do |ai|
184
+ v = { "01" => gtin, "10" => lot, "21" => serial }[ai]
185
+ "(#{ai})#{v}" if v
186
+ end.join
187
+ when "iso-15459"
188
+ sscc ? "(00)#{sscc}" : value.to_s
189
+ else
190
+ value.to_s
191
+ end
192
+ end
193
+
194
+ # Stable identity key across representations: scheme-independent for
195
+ # GS1-keyed identifiers, scheme-qualified otherwise.
196
+ #
197
+ # @return [String]
198
+ def canonical_id
199
+ if gtin
200
+ "gtin:#{gtin}#{lot ? "/lot:#{lot}" : ''}#{serial ? "/ser:#{serial}" : ''}"
201
+ elsif sscc
202
+ "sscc:#{sscc}"
203
+ elsif scheme == "urn"
204
+ value.to_s
205
+ else
206
+ "#{scheme}:#{value}"
207
+ end
208
+ end
209
+
210
+ # Granularity predicates (model? / batch? / item?) are generated by
211
+ # lutaml-model from the granularity value enumeration.
212
+
213
+ # @return [Boolean] check digits valid for the carried GS1 keys
214
+ def valid_keys?
215
+ [gtin, sscc].compact.all? { |key| Gs1.valid_check_digit?(key) }
216
+ end
217
+
218
+ def to_s
219
+ canonical
220
+ end
221
+
222
+ private_class_method def self.granularity_for(serial, lot)
223
+ serial ? "item" : (lot ? "batch" : "model")
224
+ end
225
+
226
+ private_class_method def self.dl_identity_pairs?(segments)
227
+ return false unless segments.size.even? && segments.size >= 2
228
+
229
+ segments.each_slice(2).all? { |ai, _| Gs1::DL_PATH_AIS.include?(ai) }
230
+ end
231
+
232
+ private_class_method def self.gbt33993_gtin(segments)
233
+ segments.filter_map do |segment|
234
+ next unless /\A\d{13,14}\z/.match?(segment)
235
+
236
+ segment if Gs1.valid_check_digit?(segment)
237
+ end.first
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+
6
+ module Unidpp
7
+ # Temporal interval of a link's validity.
8
+ class LinkInterval < Lutaml::Model::Serializable
9
+ attribute :from, "Lutaml::Model::Type::DateTime"
10
+ attribute :until, "Lutaml::Model::Type::DateTime"
11
+
12
+ json do
13
+ map "from", to: :from, render_nil: false
14
+ map "until", to: :until, render_nil: false
15
+ end
16
+
17
+ yaml do
18
+ map "from", to: :from, render_nil: false
19
+ map "until", to: :until, render_nil: false
20
+ end
21
+
22
+ # @param t [Time, DateTime]
23
+ # @return [Boolean]
24
+ def covers?(t)
25
+ t = t.is_a?(DateTime) ? t : DateTime.parse(t.to_s)
26
+ (!from || t >= from) && (!self.until || t < self.until)
27
+ end
28
+ end
29
+
30
+ # Typed relationship edge between passports (I5). Edge types carry
31
+ # different legal and lifecycle semantics:
32
+ #
33
+ # association R1 loose (compatibility, supply) — navigational only
34
+ # derivation R2 ancestral (split/mix/refine/remanufacture)
35
+ # installation R3 structural parent<->child, temporal (see below)
36
+ # type-lineage R4 type versioning, derived types, config vectors
37
+ # profile-of R5 lens<->core (not a passport-to-passport edge)
38
+ # custody R6 social/control edges, orthogonal to structure
39
+ # membership R7 group nodes (shipments, kits, recall sets)
40
+ #
41
+ # Installation is its own event class, not generic containment: an
42
+ # installed component is a different legal object from the standalone
43
+ # one (permanent alteration, pairing, lifecycle coupling, and
44
+ # bidirectional parent knowledge — with the disclosure hazard that
45
+ # creates, hence visibility classes and salted parent commitments:
46
+ # proof-of-binding without knowledge-of-parent, I12).
47
+ #
48
+ # (De)serialization is generated by lutaml-model from the attribute +
49
+ # mapping declarations below — no hand-rolled to_h / from_h.
50
+ class PassportLink < Lutaml::Model::Serializable
51
+ TYPES = %w[
52
+ association derivation installation type-lineage profile-of custody membership
53
+ ].freeze
54
+ DIRECTIONS = %w[upstream downstream undirected].freeze
55
+ BINDING_METHODS = %w[
56
+ none fastened soldered glued welded potted integrated keyed
57
+ ].freeze
58
+ RECOVERABILITY = %w[restorable harvestable destructive absorbing].freeze
59
+ PAIRINGS = %w[none firmware key module].freeze
60
+ VISIBILITIES = %w[public restricted blind escrowed].freeze
61
+ ESCROWS = %w[none trustee].freeze
62
+
63
+ attribute :link_type, :string, values: TYPES
64
+ attribute :direction, :string, values: DIRECTIONS,
65
+ default: -> { "downstream" }
66
+ attribute :child, ProductIdentifier
67
+ attribute :parent, ProductIdentifier
68
+ attribute :interval, LinkInterval
69
+ # Binding strength: how the child is held by the parent.
70
+ attribute :binding_method, :string, values: BINDING_METHODS,
71
+ default: -> { "none" }
72
+ # Recoverability spectrum determines identity continuity:
73
+ # restorable disassembly restores a marketable standalone object
74
+ # harvestable recovered but altered; continues with parent history
75
+ # destructive child ceases; flows to material passport (derivation)
76
+ # absorbing identity dissolves (paint, adhesive)
77
+ attribute :recoverability, :string, values: RECOVERABILITY
78
+ # Parent-scoped slot identity ("battery slot 1 of car X").
79
+ attribute :slot_id, :string
80
+ # Pairing / re-identification on install.
81
+ attribute :pairing, :string, values: PAIRINGS, default: -> { "none" }
82
+ # Permanent alterations performed by the installation.
83
+ attribute :alterations, :string, collection: true, default: -> { [] }
84
+ # Edge visibility class (I12): blind is the consumer-install default —
85
+ # a child->parent edge reveals where a thing is.
86
+ attribute :visibility, :string, values: VISIBILITIES,
87
+ default: -> { "public" }
88
+ attribute :escrow, :string, values: ESCROWS, default: -> { "none" }
89
+ attribute :audiences, :string, collection: true, default: -> { [] }
90
+ # Proof-of-binding (blind edges): salted commitment to the parent,
91
+ # stored in the child's log; verifiable without revealing the parent.
92
+ attribute :parent_commitment, :string
93
+ attribute :commitment_salt, :string
94
+
95
+ json do
96
+ map "link_type", to: :link_type
97
+ map "direction", to: :direction
98
+ map "child", to: :child, render_nil: false
99
+ map "parent", to: :parent, render_nil: false
100
+ map "interval", to: :interval, render_nil: false
101
+ map "binding_method", to: :binding_method
102
+ map "recoverability", to: :recoverability, render_nil: false
103
+ map "slot_id", to: :slot_id, render_nil: false
104
+ map "pairing", to: :pairing
105
+ map "alterations", to: :alterations, render_nil: false
106
+ map "visibility", to: :visibility
107
+ map "escrow", to: :escrow
108
+ map "audiences", to: :audiences, render_nil: false
109
+ map "parent_commitment", to: :parent_commitment, render_nil: false
110
+ map "commitment_salt", to: :commitment_salt, render_nil: false
111
+ end
112
+
113
+ yaml do
114
+ map "link_type", to: :link_type
115
+ map "direction", to: :direction
116
+ map "child", to: :child, render_nil: false
117
+ map "parent", to: :parent, render_nil: false
118
+ map "interval", to: :interval, render_nil: false
119
+ map "binding_method", to: :binding_method
120
+ map "recoverability", to: :recoverability, render_nil: false
121
+ map "slot_id", to: :slot_id, render_nil: false
122
+ map "pairing", to: :pairing
123
+ map "alterations", to: :alterations, render_nil: false
124
+ map "visibility", to: :visibility
125
+ map "escrow", to: :escrow
126
+ map "audiences", to: :audiences, render_nil: false
127
+ map "parent_commitment", to: :parent_commitment, render_nil: false
128
+ map "commitment_salt", to: :commitment_salt, render_nil: false
129
+ end
130
+
131
+ # -- construction ---------------------------------------------------------
132
+
133
+ # Builds an installation edge (R3) with the full binding schema. When
134
+ # +visibility+ is blind (the consumer-install default), the parent is
135
+ # replaced by a salted commitment: the child proves it is validly
136
+ # installed in *some* parent without revealing which. The salt is the
137
+ # disclosure key (escrowed or revealed at ceremony).
138
+ #
139
+ # @return [PassportLink]
140
+ def self.install(child:, parent:, visibility: "blind",
141
+ salt: SecureRandom.hex(16), **attrs)
142
+ link = new(link_type: "installation", direction: "downstream",
143
+ child: child, parent: parent, visibility: visibility,
144
+ **attrs)
145
+ if visibility == "blind"
146
+ link.parent = nil
147
+ link.parent_commitment = PassportLink.parent_commitment(child,
148
+ parent, salt)
149
+ link.commitment_salt = salt
150
+ end
151
+ link
152
+ end
153
+
154
+ # @return [String] hex SHA-256 over the canonical child and parent
155
+ # identities and the salt
156
+ def self.parent_commitment(child, parent, salt)
157
+ Digest::SHA256.hexdigest(
158
+ [child.canonical, parent.canonical, salt].join("\n")
159
+ )
160
+ end
161
+
162
+ # -- queries --------------------------------------------------------------
163
+
164
+ # Edge-type and visibility predicates (installation? / blind? /
165
+ # escrowed?) are generated by lutaml-model from the corresponding
166
+ # value enumerations.
167
+
168
+ def hidden_parent?
169
+ blind? && parent.nil? && !parent_commitment.nil?
170
+ end
171
+
172
+ # Does disassembly of this installation restore a marketable object
173
+ # with identity continuity? (R3 vs R2 boundary.)
174
+ def identity_continuity?
175
+ %w[restorable harvestable].include?(recoverability)
176
+ end
177
+
178
+ # Proof-of-binding check. For visible edges this is identity equality;
179
+ # for blind edges the candidate parent is recomputed against the
180
+ # stored salted commitment — full disclosure by ceremony only.
181
+ #
182
+ # @param candidate [ProductIdentifier] the parent being tested
183
+ # @return [Boolean]
184
+ def proves_parent?(candidate)
185
+ return false if candidate.nil?
186
+
187
+ if parent
188
+ parent.canonical == candidate.canonical
189
+ elsif parent_commitment
190
+ PassportLink.parent_commitment(child, candidate,
191
+ commitment_salt) == parent_commitment
192
+ else
193
+ false
194
+ end
195
+ end
196
+ end
197
+ end