harnex 0.7.12 → 0.7.14

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.
@@ -1,4 +1,5 @@
1
1
  require "digest"
2
+ require "fileutils"
2
3
  require "json"
3
4
 
4
5
  module Harnex
@@ -10,70 +11,503 @@ module Harnex
10
11
  MAX_CANONICAL_ARTIFACTS = 50
11
12
  MAX_EVIDENCE_ITEMS = 20
12
13
  MAX_STRING_LENGTH = 2_000
14
+ MAX_DIAGNOSTICS = 100
15
+
16
+ REPORT_STATUSES = %w[in_progress pass fail blocked unknown].freeze
17
+ VALIDATION_STATUSES = %w[in_progress pass fail not_run unknown].freeze
18
+ OUTCOME_STATUSES = %w[accepted rejected no_change unknown].freeze
19
+ ACCEPTED_OUTCOME_STATUSES = %w[accepted no_change].freeze
20
+
21
+ ValidationResult = Struct.new(
22
+ :ok, :status, :path, :bytes, :sha256, :schema, :diagnostics, :report,
23
+ keyword_init: true
24
+ ) do
25
+ def public_payload(final: false)
26
+ {
27
+ "ok" => !!ok,
28
+ "status" => status,
29
+ "path" => path,
30
+ "bytes" => bytes,
31
+ "sha256" => sha256,
32
+ "schema" => schema,
33
+ "final" => !!final,
34
+ "diagnostics" => diagnostics || []
35
+ }
36
+ end
37
+ end
13
38
 
14
39
  module_function
15
40
 
16
- def ingest(path)
41
+ def validate(path, final: false)
17
42
  report_path = File.expand_path(path.to_s)
18
- return missing(report_path) unless File.file?(report_path)
43
+ return validation_failure(
44
+ report_path,
45
+ status: "missing",
46
+ diagnostics: [diagnostic("report_missing", "$", "artifact report file does not exist")]
47
+ ) unless File.file?(report_path)
19
48
 
20
49
  bytes = File.size(report_path)
21
50
  sha256 = file_sha256(report_path)
22
51
  if bytes > MAX_BYTES
23
- return warning(
52
+ return validation_failure(
24
53
  report_path,
54
+ status: "oversized",
25
55
  bytes: bytes,
26
56
  sha256: sha256,
27
- ingest_status: "oversized",
28
- warning: "artifact report is #{bytes} bytes; max is #{MAX_BYTES} bytes"
57
+ diagnostics: [
58
+ diagnostic("report_oversized", "$", "artifact report exceeds the #{MAX_BYTES}-byte limit")
59
+ ]
29
60
  )
30
61
  end
31
62
 
32
63
  parsed = JSON.parse(File.read(report_path, mode: "rb"))
33
64
  unless parsed.is_a?(Hash)
34
- return warning(
65
+ return validation_failure(
35
66
  report_path,
67
+ status: "malformed",
36
68
  bytes: bytes,
37
69
  sha256: sha256,
38
- ingest_status: "malformed",
39
- warning: "artifact report must be a JSON object"
70
+ diagnostics: [diagnostic("object_required", "$", "artifact report must be a JSON object")]
40
71
  )
41
72
  end
42
73
 
43
- schema = parsed["schema"].to_s
44
- unless schema == SCHEMA
45
- return warning(
46
- report_path,
47
- bytes: bytes,
48
- sha256: sha256,
49
- ingest_status: "unsupported_schema",
50
- schema: schema.empty? ? nil : bounded_string(schema),
51
- warning: "unsupported artifact report schema #{schema.inspect}; expected #{SCHEMA}"
52
- )
53
- end
74
+ schema = parsed["schema"].is_a?(String) ? bounded_string(parsed["schema"]) : nil
75
+ diagnostics = validate_document(parsed, final: final)
76
+ status = if parsed["schema"] != SCHEMA
77
+ "unsupported_schema"
78
+ elsif diagnostics.empty?
79
+ "valid"
80
+ else
81
+ "invalid"
82
+ end
54
83
 
55
- build_payload(report_path, bytes: bytes, sha256: sha256, report: parsed)
56
- rescue JSON::ParserError => e
57
- warning(
84
+ ValidationResult.new(
85
+ ok: diagnostics.empty?,
86
+ status: status,
87
+ path: report_path,
88
+ bytes: bytes,
89
+ sha256: sha256,
90
+ schema: schema,
91
+ diagnostics: diagnostics,
92
+ report: parsed
93
+ )
94
+ rescue JSON::ParserError
95
+ validation_failure(
58
96
  report_path,
97
+ status: "malformed",
59
98
  bytes: safe_file_size(report_path),
60
99
  sha256: safe_file_sha256(report_path),
61
- ingest_status: "malformed",
62
- warning: "malformed artifact report JSON: #{bounded_string(e.message)}"
100
+ diagnostics: [diagnostic("json_invalid", "$", "artifact report is not valid JSON")]
63
101
  )
64
- rescue StandardError => e
65
- warning(
102
+ rescue StandardError
103
+ validation_failure(
66
104
  report_path,
105
+ status: "error",
67
106
  bytes: safe_file_size(report_path),
68
107
  sha256: safe_file_sha256(report_path),
69
- ingest_status: "error",
70
- warning: "artifact report ingest failed: #{bounded_string(e.message)}"
108
+ diagnostics: [diagnostic("read_error", "$", "artifact report could not be read")]
109
+ )
110
+ end
111
+
112
+ def ingest(path)
113
+ result = validate(path)
114
+ case result.status
115
+ when "missing"
116
+ missing(result.path)
117
+ when "oversized"
118
+ warning(
119
+ result.path,
120
+ bytes: result.bytes,
121
+ sha256: result.sha256,
122
+ ingest_status: "oversized",
123
+ warning: "artifact report is #{result.bytes} bytes; max is #{MAX_BYTES} bytes",
124
+ diagnostics: result.diagnostics
125
+ )
126
+ when "malformed"
127
+ warning(
128
+ result.path,
129
+ bytes: result.bytes,
130
+ sha256: result.sha256,
131
+ ingest_status: "malformed",
132
+ warning: "malformed artifact report JSON",
133
+ diagnostics: result.diagnostics
134
+ )
135
+ when "unsupported_schema"
136
+ warning(
137
+ result.path,
138
+ bytes: result.bytes,
139
+ sha256: result.sha256,
140
+ ingest_status: "unsupported_schema",
141
+ schema: result.schema,
142
+ warning: "unsupported artifact report schema; expected #{SCHEMA}",
143
+ diagnostics: result.diagnostics
144
+ )
145
+ when "error"
146
+ warning(
147
+ result.path,
148
+ bytes: result.bytes,
149
+ sha256: result.sha256,
150
+ ingest_status: "error",
151
+ warning: "artifact report ingest failed",
152
+ diagnostics: result.diagnostics
153
+ )
154
+ else
155
+ payload = build_payload(
156
+ result.path,
157
+ bytes: result.bytes,
158
+ sha256: result.sha256,
159
+ report: result.report
160
+ )
161
+ unless result.ok
162
+ metadata = payload.fetch("artifact_report")
163
+ metadata["ingest_status"] = "invalid"
164
+ metadata["warning"] = "artifact report does not conform to #{SCHEMA}"
165
+ metadata["diagnostics"] = result.diagnostics
166
+ end
167
+ payload
168
+ end
169
+ end
170
+
171
+ def initialize_file(path, force: false)
172
+ report_path = File.expand_path(path.to_s)
173
+ raise ArgumentError, "artifact report path is required" if path.to_s.strip.empty?
174
+ if File.exist?(report_path) && !force
175
+ raise ArgumentError, "artifact report already exists: #{report_path} (use --force to replace it)"
176
+ end
177
+
178
+ FileUtils.mkdir_p(File.dirname(report_path))
179
+ File.write(report_path, JSON.pretty_generate(template) + "\n")
180
+ report_path
181
+ end
182
+
183
+ def template
184
+ {
185
+ "schema" => SCHEMA,
186
+ "status" => "in_progress",
187
+ "canonical_artifacts" => [],
188
+ "outcome" => {
189
+ "status" => "unknown",
190
+ "summary" => ""
191
+ },
192
+ "validation" => {
193
+ "status" => "not_run",
194
+ "commands" => [],
195
+ "final_reported" => false
196
+ },
197
+ "artifacts" => []
198
+ }
199
+ end
200
+
201
+ def accepted_final?(result)
202
+ result.ok && ACCEPTED_OUTCOME_STATUSES.include?(outcome_status(result))
203
+ end
204
+
205
+ def fingerprint(path)
206
+ report_path = File.expand_path(path.to_s)
207
+ return nil unless File.file?(report_path)
208
+
209
+ stat = File.stat(report_path)
210
+ {
211
+ "bytes" => stat.size,
212
+ "sha256" => file_sha256(report_path),
213
+ "mtime_ns" => (stat.mtime.to_r * 1_000_000_000).to_i,
214
+ "ctime_ns" => (stat.ctime.to_r * 1_000_000_000).to_i,
215
+ "inode" => stat.ino
216
+ }
217
+ rescue StandardError
218
+ nil
219
+ end
220
+
221
+ def outcome_status(result)
222
+ report = result.respond_to?(:report) ? result.report : result
223
+ return nil unless report.is_a?(Hash)
224
+
225
+ outcome = report["outcome"]
226
+ outcome.is_a?(Hash) ? outcome["status"] : nil
227
+ end
228
+
229
+ def validate_document(report, final: false)
230
+ diagnostics = []
231
+ validate_schema(report, diagnostics)
232
+ validate_optional_enum(report, "status", REPORT_STATUSES, diagnostics)
233
+ validate_string_array_field(
234
+ report,
235
+ "canonical_artifacts",
236
+ max_items: MAX_CANONICAL_ARTIFACTS,
237
+ diagnostics: diagnostics
238
+ )
239
+ validate_outcome(report["outcome"], diagnostics) if report.key?("outcome")
240
+ validate_validation(report["validation"], diagnostics) if report.key?("validation")
241
+ validate_artifacts(report["artifacts"], diagnostics) if report.key?("artifacts")
242
+ validate_final_contract(report, diagnostics) if final
243
+ diagnostics.first(MAX_DIAGNOSTICS)
244
+ end
245
+
246
+ def validate_schema(report, diagnostics)
247
+ value = report["schema"]
248
+ if !value.is_a?(String) || value.empty?
249
+ diagnostics << diagnostic("schema_required", "$.schema", "schema must be a non-empty string")
250
+ elsif value != SCHEMA
251
+ diagnostics << diagnostic("schema_unsupported", "$.schema", "schema must equal #{SCHEMA}")
252
+ end
253
+ end
254
+
255
+ def validate_optional_enum(report, key, values, diagnostics)
256
+ return unless report.key?(key)
257
+
258
+ value = report[key]
259
+ unless value.is_a?(String) && values.include?(value)
260
+ diagnostics << diagnostic("enum", "$.#{key}", "#{key} must be one of #{values.join(', ')}")
261
+ end
262
+ end
263
+
264
+ def validate_outcome(value, diagnostics)
265
+ unless value.is_a?(Hash)
266
+ diagnostics << diagnostic("object_required", "$.outcome", "outcome must be an object")
267
+ return
268
+ end
269
+
270
+ status = value["status"]
271
+ unless status.is_a?(String) && OUTCOME_STATUSES.include?(status)
272
+ diagnostics << diagnostic(
273
+ "enum",
274
+ "$.outcome.status",
275
+ "outcome.status must be one of #{OUTCOME_STATUSES.join(', ')}"
276
+ )
277
+ end
278
+ validate_optional_string(value, "summary", "$.outcome.summary", diagnostics)
279
+ validate_optional_string(value, "commit_sha", "$.outcome.commit_sha", diagnostics)
280
+ end
281
+
282
+ def validate_validation(value, diagnostics)
283
+ unless value.is_a?(Hash)
284
+ diagnostics << diagnostic("object_required", "$.validation", "validation must be an object")
285
+ return
286
+ end
287
+
288
+ if value.key?("status")
289
+ status = value["status"]
290
+ unless status.is_a?(String) && VALIDATION_STATUSES.include?(status)
291
+ diagnostics << diagnostic(
292
+ "enum",
293
+ "$.validation.status",
294
+ "validation.status must be one of #{VALIDATION_STATUSES.join(', ')}"
295
+ )
296
+ end
297
+ end
298
+
299
+ validate_commands(value["commands"], diagnostics) if value.key?("commands")
300
+ if value.key?("final_reported") && !boolean?(value["final_reported"])
301
+ diagnostics << diagnostic(
302
+ "boolean_required",
303
+ "$.validation.final_reported",
304
+ "validation.final_reported must be true or false"
305
+ )
306
+ end
307
+ end
308
+
309
+ def validate_commands(value, diagnostics)
310
+ unless value.is_a?(Array)
311
+ diagnostics << diagnostic("array_required", "$.validation.commands", "validation.commands must be an array")
312
+ return
313
+ end
314
+ if value.length > MAX_COMMANDS
315
+ diagnostics << diagnostic(
316
+ "too_many_items",
317
+ "$.validation.commands",
318
+ "validation.commands may contain at most #{MAX_COMMANDS} entries"
319
+ )
320
+ end
321
+
322
+ value.first(MAX_COMMANDS).each_with_index do |entry, index|
323
+ path = "$.validation.commands[#{index}]"
324
+ unless entry.is_a?(Hash)
325
+ diagnostics << diagnostic("object_required", path, "validation command must be an object")
326
+ next
327
+ end
328
+
329
+ validate_required_string(entry, "cmd", "#{path}.cmd", diagnostics)
330
+ unless entry["exit_code"].is_a?(Integer)
331
+ diagnostics << diagnostic(
332
+ "integer_required",
333
+ "#{path}.exit_code",
334
+ "validation command exit_code must be an integer"
335
+ )
336
+ end
337
+ validate_optional_string(entry, "status", "#{path}.status", diagnostics)
338
+ if entry.key?("duration_s") && !finite_non_negative_number?(entry["duration_s"])
339
+ diagnostics << diagnostic(
340
+ "number_required",
341
+ "#{path}.duration_s",
342
+ "validation command duration_s must be a finite non-negative number"
343
+ )
344
+ end
345
+ end
346
+ end
347
+
348
+ def validate_artifacts(value, diagnostics)
349
+ unless value.is_a?(Array)
350
+ diagnostics << diagnostic("array_required", "$.artifacts", "artifacts must be an array")
351
+ return
352
+ end
353
+ if value.length > MAX_ARTIFACTS
354
+ diagnostics << diagnostic(
355
+ "too_many_items",
356
+ "$.artifacts",
357
+ "artifacts may contain at most #{MAX_ARTIFACTS} entries"
358
+ )
359
+ end
360
+
361
+ value.first(MAX_ARTIFACTS).each_with_index do |entry, index|
362
+ path = "$.artifacts[#{index}]"
363
+ unless entry.is_a?(Hash)
364
+ diagnostics << diagnostic("object_required", path, "artifact must be an object")
365
+ next
366
+ end
367
+
368
+ validate_required_string(entry, "type", "#{path}.type", diagnostics)
369
+ validate_required_string(entry, "summary", "#{path}.summary", diagnostics)
370
+ validate_string_array_value(
371
+ entry["evidence"],
372
+ "#{path}.evidence",
373
+ max_items: MAX_EVIDENCE_ITEMS,
374
+ diagnostics: diagnostics,
375
+ required: true
376
+ )
377
+ confidence = entry["confidence"]
378
+ unless finite_number?(confidence) && confidence >= 0.0 && confidence <= 1.0
379
+ diagnostics << diagnostic(
380
+ "range",
381
+ "#{path}.confidence",
382
+ "artifact confidence must be a finite number from 0 through 1"
383
+ )
384
+ end
385
+ validate_optional_string(entry, "canonical_ref", "#{path}.canonical_ref", diagnostics)
386
+ end
387
+ end
388
+
389
+ def validate_final_contract(report, diagnostics)
390
+ unless report["status"] == "pass"
391
+ diagnostics << diagnostic("final_status", "$.status", "final report status must be pass")
392
+ end
393
+
394
+ outcome = report["outcome"]
395
+ unless outcome.is_a?(Hash)
396
+ diagnostics << diagnostic("required", "$.outcome", "final report requires an outcome object")
397
+ else
398
+ unless ACCEPTED_OUTCOME_STATUSES.include?(outcome["status"])
399
+ diagnostics << diagnostic(
400
+ "outcome_not_accepted",
401
+ "$.outcome.status",
402
+ "final outcome must be accepted or no_change"
403
+ )
404
+ end
405
+ unless non_empty_string?(outcome["summary"])
406
+ diagnostics << diagnostic(
407
+ "required",
408
+ "$.outcome.summary",
409
+ "final outcome requires a non-empty summary"
410
+ )
411
+ end
412
+ end
413
+
414
+ validation = report["validation"]
415
+ unless validation.is_a?(Hash)
416
+ diagnostics << diagnostic("required", "$.validation", "final report requires a validation object")
417
+ return
418
+ end
419
+
420
+ expected_statuses = outcome.is_a?(Hash) && outcome["status"] == "no_change" ? %w[pass not_run] : %w[pass]
421
+ unless expected_statuses.include?(validation["status"])
422
+ diagnostics << diagnostic(
423
+ "final_validation_status",
424
+ "$.validation.status",
425
+ "final validation.status must be #{expected_statuses.join(' or ')}"
426
+ )
427
+ end
428
+ unless validation["commands"].is_a?(Array)
429
+ diagnostics << diagnostic(
430
+ "required",
431
+ "$.validation.commands",
432
+ "final report requires a validation.commands array"
433
+ )
434
+ end
435
+ unless validation["final_reported"] == true
436
+ diagnostics << diagnostic(
437
+ "final_reported",
438
+ "$.validation.final_reported",
439
+ "final report requires validation.final_reported=true"
440
+ )
441
+ end
442
+
443
+ Array(validation["commands"]).first(MAX_COMMANDS).each_with_index do |entry, index|
444
+ next unless entry.is_a?(Hash) && entry["exit_code"].is_a?(Integer)
445
+ next if entry["exit_code"].zero?
446
+
447
+ diagnostics << diagnostic(
448
+ "command_failed",
449
+ "$.validation.commands[#{index}].exit_code",
450
+ "accepted final reports require validation command exit_code=0"
451
+ )
452
+ end
453
+ end
454
+
455
+ def validate_string_array_field(report, key, max_items:, diagnostics:)
456
+ return unless report.key?(key)
457
+
458
+ validate_string_array_value(
459
+ report[key],
460
+ "$.#{key}",
461
+ max_items: max_items,
462
+ diagnostics: diagnostics,
463
+ required: true
464
+ )
465
+ end
466
+
467
+ def validate_string_array_value(value, path, max_items:, diagnostics:, required:)
468
+ if value.nil? && !required
469
+ return
470
+ end
471
+ unless value.is_a?(Array)
472
+ diagnostics << diagnostic("array_required", path, "#{path.delete_prefix('$.')} must be an array")
473
+ return
474
+ end
475
+ if value.length > max_items
476
+ diagnostics << diagnostic("too_many_items", path, "#{path.delete_prefix('$.')} may contain at most #{max_items} entries")
477
+ end
478
+
479
+ value.first(max_items).each_with_index do |item, index|
480
+ next if non_empty_string?(item)
481
+
482
+ diagnostics << diagnostic(
483
+ "string_required",
484
+ "#{path}[#{index}]",
485
+ "#{path.delete_prefix('$.')} entries must be non-empty strings"
486
+ )
487
+ end
488
+ end
489
+
490
+ def validate_required_string(hash, key, path, diagnostics)
491
+ return if non_empty_string?(hash[key])
492
+
493
+ diagnostics << diagnostic("string_required", path, "#{path.delete_prefix('$.')} must be a non-empty string")
494
+ end
495
+
496
+ def validate_optional_string(hash, key, path, diagnostics)
497
+ return unless hash.key?(key)
498
+ return if hash[key].is_a?(String) && hash[key].length <= MAX_STRING_LENGTH
499
+
500
+ diagnostics << diagnostic(
501
+ "string_required",
502
+ path,
503
+ "#{path.delete_prefix('$.')} must be a string no longer than #{MAX_STRING_LENGTH} characters"
71
504
  )
72
505
  end
73
506
 
74
507
  def build_payload(path, bytes:, sha256:, report:)
75
508
  artifacts = compact_artifacts(report["artifacts"])
76
509
  validation = compact_validation(report["validation"])
510
+ outcome = compact_outcome(report["outcome"])
77
511
  payload = {
78
512
  "artifact_report" => metadata(
79
513
  path,
@@ -89,6 +523,7 @@ module Harnex
89
523
  }
90
524
  payload["validation"] = validation if validation
91
525
  payload["artifacts"] = artifacts unless artifacts.empty?
526
+ payload["outcome"] = outcome if outcome
92
527
  payload
93
528
  end
94
529
 
@@ -98,13 +533,15 @@ module Harnex
98
533
  bytes: nil,
99
534
  sha256: nil,
100
535
  ingest_status: "missing",
101
- warning: "artifact report not found"
536
+ warning: "artifact report not found",
537
+ diagnostics: [diagnostic("report_missing", "$", "artifact report file does not exist")]
102
538
  )
103
539
  end
104
540
 
105
- def warning(path, bytes:, sha256:, ingest_status:, warning:, schema: nil)
541
+ def warning(path, bytes:, sha256:, ingest_status:, warning:, schema: nil, diagnostics: nil)
106
542
  report = metadata(path, bytes: bytes, sha256: sha256, ingest_status: ingest_status, schema: schema)
107
543
  report["warning"] = bounded_string(warning)
544
+ report["diagnostics"] = Array(diagnostics).first(MAX_DIAGNOSTICS) unless Array(diagnostics).empty?
108
545
  { "artifact_report" => report }
109
546
  end
110
547
 
@@ -125,7 +562,7 @@ module Harnex
125
562
  payload["status"] = bounded_string_or_nil(value["status"])
126
563
  payload["commands"] = compact_commands(value["commands"])
127
564
  payload["final_reported"] = !!value["final_reported"] if value.key?("final_reported")
128
- payload.delete_if { |_key, v| v.nil? || v == [] }
565
+ payload.delete_if { |_key, item| item.nil? || item == [] }
129
566
  payload.empty? ? nil : payload
130
567
  end
131
568
 
@@ -139,11 +576,26 @@ module Harnex
139
576
  "status" => bounded_string_or_nil(entry["status"]),
140
577
  "duration_s" => finite_float_or_nil(entry["duration_s"])
141
578
  }
142
- compact.delete_if { |_key, v| v.nil? }
579
+ compact.delete_if { |_key, item| item.nil? }
143
580
  compact.empty? ? nil : compact
144
581
  end
145
582
  end
146
583
 
584
+ def compact_outcome(value)
585
+ return nil unless value.is_a?(Hash)
586
+
587
+ status = value["status"].to_s
588
+ return nil unless OUTCOME_STATUSES.include?(status)
589
+
590
+ payload = {
591
+ "status" => status,
592
+ "summary" => bounded_string_or_nil(value["summary"]),
593
+ "commit_sha" => bounded_string_or_nil(value["commit_sha"])
594
+ }
595
+ payload.delete_if { |_key, item| item.nil? }
596
+ payload
597
+ end
598
+
147
599
  def compact_artifacts(value)
148
600
  Array(value).first(MAX_ARTIFACTS).filter_map do |entry|
149
601
  next unless entry.is_a?(Hash)
@@ -155,11 +607,32 @@ module Harnex
155
607
  "confidence" => finite_float_or_nil(entry["confidence"]),
156
608
  "canonical_ref" => bounded_string_or_nil(entry["canonical_ref"])
157
609
  }
158
- compact.delete_if { |_key, v| v.nil? || v == [] }
610
+ compact.delete_if { |_key, item| item.nil? || item == [] }
159
611
  compact.empty? ? nil : compact
160
612
  end
161
613
  end
162
614
 
615
+ def validation_failure(path, status:, diagnostics:, bytes: nil, sha256: nil)
616
+ ValidationResult.new(
617
+ ok: false,
618
+ status: status,
619
+ path: path,
620
+ bytes: bytes,
621
+ sha256: sha256,
622
+ schema: nil,
623
+ diagnostics: diagnostics.first(MAX_DIAGNOSTICS),
624
+ report: nil
625
+ )
626
+ end
627
+
628
+ def diagnostic(code, path, message)
629
+ {
630
+ "code" => code,
631
+ "path" => path,
632
+ "message" => bounded_string(message)
633
+ }
634
+ end
635
+
163
636
  def string_array(value, max_items:)
164
637
  Array(value).first(max_items).filter_map do |item|
165
638
  text = bounded_string_or_nil(item)
@@ -197,6 +670,22 @@ module Harnex
197
670
  nil
198
671
  end
199
672
 
673
+ def finite_number?(value)
674
+ value.is_a?(Numeric) && value.finite?
675
+ end
676
+
677
+ def finite_non_negative_number?(value)
678
+ finite_number?(value) && value >= 0
679
+ end
680
+
681
+ def non_empty_string?(value)
682
+ value.is_a?(String) && !value.empty? && value.length <= MAX_STRING_LENGTH
683
+ end
684
+
685
+ def boolean?(value)
686
+ value == true || value == false
687
+ end
688
+
200
689
  def file_sha256(path)
201
690
  digest = Digest::SHA256.new
202
691
  File.open(path, "rb") do |file|