openexit 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 25b31579dbf8907d27ac8b92f4c0dfad8ab692786e9be31e6a47571c47de1051
4
+ data.tar.gz: 99c48913267e0089fa03f4d5ee75907a2400be633660316967f94a431a223d13
5
+ SHA512:
6
+ metadata.gz: 6cb48243a04cf9bac3e782dc73a7331642679dbbc4894488b08f4a9763b47ac2b8a4d59c267a0f72263b51f0ea4c083e3eb0634568e4345b20edad634e6142d1
7
+ data.tar.gz: d00467132dbc4878e98534457c1bb3e2e05c40ebd29919812b61a286e2fd40bd2fb5500269b8aba03b580905962c654d06c0cc1263b5ce0515844e492398dd01
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenExit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # OpenExit
2
+
3
+ > Portable state for any application.
4
+
5
+ This gem implements PASP — the Portable Application State Protocol — for Ruby.
6
+
7
+ Gem version: 0.1.0
8
+ PASP protocol: 1.0
@@ -0,0 +1,98 @@
1
+ require "json"
2
+ module OpenExit
3
+ class Bundle
4
+ attr_reader :path
5
+ def initialize(path) = @path = File.expand_path(path.to_s)
6
+ def self.open(path) = new(path)
7
+ def inspect = OpenExit.inspect_bundle(path)
8
+ def verify = OpenExit.verify_bundle(path)
9
+ end
10
+ module BundleOps
11
+ module_function
12
+ def json_file(path, code)
13
+ JSON.parse(File.read(path, encoding: "UTF-8"))
14
+ rescue StandardError => e
15
+ raise PaspError.new(code, "invalid or missing JSON file: #{path}", context: e)
16
+ end
17
+ def lines(path, code)
18
+ return enum_for(__method__, path, code) unless block_given?
19
+ File.foreach(path, encoding: "UTF-8").with_index(1) do |line, number|
20
+ next if line.strip.empty?
21
+ begin value = JSON.parse(line); rescue JSON::ParserError => e; raise PaspError.new(code, "invalid NDJSON at #{path}:#{number}", context: e); end
22
+ yield number, value
23
+ end
24
+ rescue SystemCallError, EncodingError => e
25
+ raise PaspError.new(code, "cannot read NDJSON: #{path}", context: e)
26
+ end
27
+ def resource(root, entry, deep)
28
+ name = entry["name"]
29
+ descriptor_path = Paths.safe_path(root, entry["descriptor"])
30
+ descriptor = json_file(descriptor_path, "PASP_INVALID_RESOURCE")
31
+ Validation.validate_document("resource", descriptor, "PASP_INVALID_RESOURCE")
32
+ raise PaspError.new("PASP_INVALID_RESOURCE", "descriptor name mismatch") unless descriptor["name"] == name
33
+ schema = descriptor["schema"]
34
+ if schema.is_a?(String)
35
+ schema_path = Paths.safe_path(root, schema)
36
+ raise PaspError.new("PASP_MISSING_SCHEMA", "missing schema: #{schema}") unless File.file?(schema_path)
37
+ schema = json_file(schema_path, "PASP_MISSING_SCHEMA")
38
+ end
39
+ total = 0; chunks = []
40
+ descriptor.fetch("chunks").each_with_index do |raw, index|
41
+ expected = index + 1
42
+ expected_path = "resources/#{name}/%08d.ndjson" % expected
43
+ raise PaspError.new("PASP_INVALID_RESOURCE", "invalid chunk order/path") unless raw["sequence"] == expected && raw["path"] == expected_path
44
+ chunk_path = Paths.safe_path(root, raw["path"])
45
+ raise PaspError.new("PASP_INVALID_RESOURCE", "missing chunk") unless File.file?(chunk_path)
46
+ if deep
47
+ Integrity.verify_file(chunk_path, raw["sha256"], raw["uncompressedBytes"])
48
+ count = 0
49
+ lines(chunk_path, "PASP_INVALID_RECORD") do |line_no, record|
50
+ raise PaspError.new("PASP_INVALID_RECORD", "record is not an object") unless record.is_a?(Hash)
51
+ missing = Array(descriptor["identity"]).any? { |id| !record.key?(id) }
52
+ raise PaspError.new("PASP_INVALID_RECORD", "missing identity at line #{line_no}") if missing
53
+ count += 1
54
+ end
55
+ raise PaspError.new("PASP_INVALID_RESOURCE", "chunk record count mismatch") unless count == raw["recordCount"]
56
+ end
57
+ total += raw["recordCount"].to_i
58
+ chunks << Models::ResourceChunk.new(sequence: raw["sequence"], path: raw["path"], record_count: raw["recordCount"], uncompressed_bytes: raw["uncompressedBytes"], sha256: raw["sha256"])
59
+ end
60
+ raise PaspError.new("PASP_INVALID_RESOURCE", "resource record count mismatch") unless total == descriptor["recordCount"]
61
+ Models::Resource.new(name: name, schema: schema, identity: descriptor["identity"], record_count: descriptor["recordCount"], chunks: chunks, descriptor: entry["descriptor"])
62
+ end
63
+ def assets(root, raw, deep)
64
+ count = raw["count"].to_i; total = raw["totalBytes"].to_i; index = raw["index"]
65
+ return Models::AssetSummary.new(count: 0, total_bytes: 0, index: nil) if count == 0 && index.nil?
66
+ index_path = Paths.safe_path(root, index || "assets/index.ndjson")
67
+ raise PaspError.new("PASP_MISSING_ASSET", "missing asset index") unless File.file?(index_path)
68
+ seen = 0; bytes = 0
69
+ lines(index_path, "PASP_MISSING_ASSET") do |_n, asset|
70
+ Validation.validate_document("asset", asset, "PASP_MISSING_ASSET")
71
+ asset_path = Paths.safe_path(root, asset["path"])
72
+ raise PaspError.new("PASP_MISSING_ASSET", "missing asset") unless File.file?(asset_path)
73
+ Integrity.verify_file(asset_path, asset["sha256"], asset["byteLength"]) if deep
74
+ seen += 1; bytes += asset["byteLength"]
75
+ end
76
+ raise PaspError.new("PASP_MISSING_ASSET", "asset summary mismatch") unless seen == count && bytes == total
77
+ Models::AssetSummary.new(count: count, total_bytes: total, index: index)
78
+ end
79
+ def inspect_bundle(directory, deep)
80
+ root = File.expand_path(directory.to_s)
81
+ raise PaspError.new("PASP_MALFORMED_PACKAGE", "not a PASP directory") unless File.directory?(root)
82
+ manifest_path = Paths.safe_path(root, "manifest.json")
83
+ raise PaspError.new("PASP_MALFORMED_PACKAGE", "missing manifest.json") unless File.file?(manifest_path)
84
+ manifest = Manifest.parse(Pathname.new(manifest_path)); Validation.validate_manifest(manifest)
85
+ raw = manifest.raw; names = raw["resources"].map { |e| e["name"] }
86
+ resources = raw["resources"].map { |e| resource(root, e, deep) }
87
+ assets = assets(root, raw["assets"], deep)
88
+ relationships = 0
89
+ if raw["relationships"]
90
+ rels = json_file(Paths.safe_path(root, raw["relationships"]), "PASP_INVALID_RELATIONSHIP")
91
+ raise PaspError.new("PASP_INVALID_RELATIONSHIP", "relationships must be an array") unless rels.is_a?(Array)
92
+ rels.each { |rel| Validation.validate_document("relationship", rel, "PASP_INVALID_RELATIONSHIP"); raise PaspError.new("PASP_INVALID_RELATIONSHIP", "missing endpoint resource") unless names.include?(rel.dig("from", "resource")) && names.include?(rel.dig("to", "resource")) }
93
+ relationships = rels.length
94
+ end
95
+ Models::InspectionResult.new(valid: true, pasp_version: raw["paspVersion"], package_id: raw["packageId"], export_id: raw["exportId"], scope: Models::Scope.new(**raw["scope"].transform_keys(&:to_sym)), producer: Models::ProducerInfo.new(**raw["producer"].transform_keys(&:to_sym)), consistency: Models::Consistency.new(**raw["consistency"].transform_keys(&:to_sym)), resources: resources, assets: assets, relationships: relationships, integrity: Models::IntegrityInfo.new(**raw["integrity"].transform_keys(&:to_sym)))
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,4 @@
1
+ module OpenExit
2
+ SCHEMA_NAMES = %w[manifest scope resource resource-chunk asset relationship checkpoint event inspection-result].freeze
3
+ CONSISTENCY_LEVELS = %w[snapshot bounded best_effort].freeze
4
+ end
@@ -0,0 +1,11 @@
1
+ module OpenExit
2
+ class Error < StandardError; end
3
+ class PaspError < Error
4
+ attr_reader :code, :context
5
+ def initialize(code, message, context: nil)
6
+ @code = code
7
+ @context = context
8
+ super(message)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ require "digest"
2
+ module OpenExit
3
+ module Integrity
4
+ module_function
5
+ def hash_file(path)
6
+ digest = Digest::SHA256.new
7
+ bytes = 0
8
+ File.open(path, "rb") { |io| while (chunk = io.read(1024 * 1024)); digest.update(chunk); bytes += chunk.bytesize; end }
9
+ [digest.hexdigest, bytes]
10
+ rescue SystemCallError => e
11
+ raise PaspError.new("PASP_MALFORMED_PACKAGE", "cannot read file: #{path}", context: e)
12
+ end
13
+ def verify_file(path, expected, expected_bytes = nil)
14
+ actual, bytes = hash_file(path)
15
+ raise PaspError.new("PASP_CHECKSUM_MISMATCH", "checksum mismatch: #{path}") unless actual == expected
16
+ raise PaspError.new("PASP_CHECKSUM_MISMATCH", "byte length mismatch: #{path}") if expected_bytes && bytes != expected_bytes
17
+ true
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ require "json"
2
+ module OpenExit
3
+ module Manifest
4
+ module_function
5
+ def parse(source)
6
+ value = if source.is_a?(Hash) then source.dup
7
+ elsif source.respond_to?(:to_path) then JSON.parse(File.read(source.to_path, encoding: "UTF-8"))
8
+ elsif source.respond_to?(:read) then JSON.parse(source.read)
9
+ elsif source.is_a?(String) then JSON.parse(source)
10
+ else raise TypeError, "manifest source must be a Hash, path, IO, or JSON string" end
11
+ raise PaspError.new("PASP_INVALID_MANIFEST", "manifest must be a JSON object") unless value.is_a?(Hash)
12
+ Models::Manifest.new(value)
13
+ rescue JSON::ParserError, ArgumentError, EncodingError, Errno::ENOENT, IOError => e
14
+ raise PaspError.new("PASP_INVALID_MANIFEST", "cannot parse manifest JSON", context: e)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module OpenExit
2
+ module Models
3
+ Scope = Struct.new(:type, :id, :metadata, keyword_init: true)
4
+ ProducerInfo = Struct.new(:name, :version, keyword_init: true)
5
+ Consistency = Struct.new(:level, :metadata, keyword_init: true)
6
+ IntegrityInfo = Struct.new(:algorithm, :checksums, keyword_init: true)
7
+ ResourceChunk = Struct.new(:sequence, :path, :record_count, :uncompressed_bytes, :sha256, keyword_init: true)
8
+ Resource = Struct.new(:name, :schema, :identity, :record_count, :chunks, :descriptor, keyword_init: true)
9
+ AssetSummary = Struct.new(:count, :total_bytes, :index, keyword_init: true)
10
+ InspectionResult = Struct.new(:valid, :pasp_version, :package_id, :export_id, :scope, :producer, :consistency, :resources, :assets, :relationships, :integrity, keyword_init: true)
11
+ Manifest = Struct.new(:raw) do
12
+ def [](key) = raw[key.to_s] || raw[key.to_sym]
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ module OpenExit
2
+ module Paths
3
+ module_function
4
+ def validate_package_path(value)
5
+ unless value.is_a?(String) && !value.empty? && !value.include?("\0") && !value.include?("\\") && !value.start_with?("/", "~") && value !~ /\A[A-Za-z]:/ && value.split("/").none? { |p| p.empty? || p == "." || p == ".." }
6
+ raise PaspError.new("PASP_PATH_TRAVERSAL", "unsafe package path: #{value}")
7
+ end
8
+ nil
9
+ end
10
+ def safe_path(root, value)
11
+ validate_package_path(value)
12
+ base = File.realpath(root)
13
+ candidate = File.expand_path(value.tr("/", File::SEPARATOR), base)
14
+ real = File.exist?(candidate) ? File.realpath(candidate) : candidate
15
+ unless real == base || real.start_with?(base + File::SEPARATOR)
16
+ raise PaspError.new("PASP_PATH_TRAVERSAL", "path escapes package: #{value}")
17
+ end
18
+ candidate
19
+ rescue Errno::ENOENT
20
+ candidate
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openexit.dev/pasp/1.0/schemas/asset.schema.json",
4
+ "title": "PASP Asset",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["id", "path", "sha256", "byteLength"],
8
+ "properties": {
9
+ "id": { "type": "string", "minLength": 1 },
10
+ "path": { "type": "string", "pattern": "^assets/objects/[A-Za-z0-9][A-Za-z0-9._/-]*$" },
11
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
12
+ "byteLength": { "type": "integer", "minimum": 0 },
13
+ "name": { "type": "string" },
14
+ "mediaType": { "type": "string" },
15
+ "metadata": { "type": "object" }
16
+ }
17
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openexit.dev/pasp/1.0/schemas/checkpoint.schema.json",
4
+ "title": "PASP Checkpoint",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["resource", "value"],
8
+ "properties": {
9
+ "resource": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
10
+ "value": {}
11
+ }
12
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openexit.dev/pasp/1.0/schemas/event.schema.json",
4
+ "title": "PASP Adapter Event",
5
+ "type": "object",
6
+ "required": ["type"],
7
+ "properties": {
8
+ "type": { "enum": ["export_begin", "resource_begin", "schema", "record", "checkpoint", "relationship", "asset", "resource_end", "export_end"] },
9
+ "scope": { "$ref": "scope.schema.json" },
10
+ "resource": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
11
+ "schema": { "type": "object" },
12
+ "schemaRef": { "type": "string" },
13
+ "value": {},
14
+ "relationship": { "$ref": "relationship.schema.json" },
15
+ "asset": { "$ref": "asset.schema.json" }
16
+ },
17
+ "allOf": [
18
+ { "if": { "properties": { "type": { "const": "export_begin" } } }, "then": { "required": ["scope"] } },
19
+ { "if": { "properties": { "type": { "enum": ["resource_begin", "resource_end", "schema", "record", "checkpoint"] } } }, "then": { "required": ["resource"] } },
20
+ { "if": { "properties": { "type": { "const": "record" } } }, "then": { "required": ["value"] } },
21
+ { "if": { "properties": { "type": { "const": "checkpoint" } } }, "then": { "required": ["value"] } },
22
+ { "if": { "properties": { "type": { "const": "relationship" } } }, "then": { "required": ["relationship"] } },
23
+ { "if": { "properties": { "type": { "const": "asset" } } }, "then": { "required": ["asset"] } }
24
+ ]
25
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openexit.dev/pasp/1.0/schemas/inspection-result.schema.json",
4
+ "title": "PASP Inspection Result",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["valid", "paspVersion", "resources", "assets", "errors"],
8
+ "properties": {
9
+ "valid": { "type": "boolean" },
10
+ "paspVersion": { "type": "string" },
11
+ "resources": { "type": "integer", "minimum": 0 },
12
+ "assets": { "type": "integer", "minimum": 0 },
13
+ "errors": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": { "code": { "type": "string", "pattern": "^PASP_[A-Z0-9_]+$" }, "message": { "type": "string" }, "path": { "type": "string" } } } }
14
+ }
15
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openexit.dev/pasp/1.0/schemas/manifest.schema.json",
4
+ "title": "PASP Manifest",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["format", "paspVersion", "packageId", "exportId", "createdAt", "producer", "scope", "consistency", "resources", "assets", "integrity"],
8
+ "properties": {
9
+ "format": { "const": "openexit.bundle" },
10
+ "paspVersion": { "const": "1.0" },
11
+ "packageId": { "type": "string", "minLength": 1 },
12
+ "exportId": { "type": "string", "minLength": 1 },
13
+ "createdAt": { "type": "string", "format": "date-time" },
14
+ "producer": { "type": "object", "additionalProperties": false, "required": ["name", "version"], "properties": { "name": { "type": "string", "minLength": 1 }, "version": { "type": "string", "minLength": 1 } } },
15
+ "scope": { "$ref": "scope.schema.json" },
16
+ "consistency": { "type": "object", "additionalProperties": false, "required": ["level"], "properties": { "level": { "enum": ["snapshot", "bounded", "best_effort"] }, "metadata": { "type": "object" } } },
17
+ "resources": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "descriptor"], "properties": { "name": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" }, "descriptor": { "type": "string", "pattern": "^resources/[A-Za-z0-9][A-Za-z0-9._-]{0,127}/resource\\.json$" } } } },
18
+ "assets": { "type": "object", "additionalProperties": false, "required": ["count", "totalBytes"], "properties": { "count": { "type": "integer", "minimum": 0 }, "totalBytes": { "type": "integer", "minimum": 0 }, "index": { "type": "string", "const": "assets/index.ndjson" } } },
19
+ "integrity": { "type": "object", "additionalProperties": false, "required": ["algorithm"], "properties": { "algorithm": { "const": "sha256" }, "checksums": { "type": "string", "const": "checksums.sha256" } } },
20
+ "relationships": { "type": "string", "const": "relationships.json" }
21
+ }
22
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openexit.dev/pasp/1.0/schemas/relationship.schema.json",
4
+ "title": "PASP Relationship",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["id", "from", "to", "cardinality"],
8
+ "properties": {
9
+ "id": { "type": "string", "minLength": 1 },
10
+ "from": { "$ref": "#/$defs/endpoint" },
11
+ "to": { "$ref": "#/$defs/endpoint" },
12
+ "cardinality": { "enum": ["one-to-one", "one-to-many", "many-to-one", "many-to-many", "reference"] },
13
+ "metadata": { "type": "object" }
14
+ },
15
+ "$defs": {
16
+ "endpoint": {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "required": ["resource", "pointer"],
20
+ "properties": {
21
+ "resource": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
22
+ "pointer": { "type": "string", "pattern": "^(/([^/~]|~[01])*)*$" }
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openexit.dev/pasp/1.0/schemas/resource-chunk.schema.json",
4
+ "title": "PASP Resource Chunk",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["sequence", "path", "recordCount", "uncompressedBytes", "sha256"],
8
+ "properties": {
9
+ "sequence": { "type": "integer", "minimum": 1 },
10
+ "path": { "type": "string", "pattern": "^resources/[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[0-9]{8}\\.ndjson$" },
11
+ "recordCount": { "type": "integer", "minimum": 0 },
12
+ "uncompressedBytes": { "type": "integer", "minimum": 0 },
13
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
14
+ "firstIdentity": {},
15
+ "lastIdentity": {},
16
+ "completedAt": { "type": "string", "format": "date-time" }
17
+ }
18
+ }
19
+
20
+
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openexit.dev/pasp/1.0/schemas/resource.schema.json",
4
+ "title": "PASP Resource",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["name", "schema", "identity", "recordCount", "chunks"],
8
+ "properties": {
9
+ "name": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
10
+ "schema": { "oneOf": [{ "type": "string", "pattern": "^schemas/[A-Za-z0-9._/-]+\\.schema\\.json$" }, { "type": "object" }] },
11
+ "schemaId": { "type": "string", "minLength": 1 },
12
+ "schemaVersion": { "type": "string", "minLength": 1 },
13
+ "identity": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
14
+ "recordCount": { "type": "integer", "minimum": 0 },
15
+ "chunks": { "type": "array", "items": { "$ref": "resource-chunk.schema.json" } },
16
+ "ordered": { "type": "boolean" },
17
+ "metadata": { "type": "object" },
18
+ "capturedAt": { "type": "string", "format": "date-time" },
19
+ "snapshotToken": { "type": "string" }
20
+ }
21
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openexit.dev/pasp/1.0/schemas/scope.schema.json",
4
+ "title": "PASP Scope",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["type", "id"],
8
+ "properties": {
9
+ "type": { "type": "string", "minLength": 1 },
10
+ "id": { "type": "string", "minLength": 1 },
11
+ "metadata": { "type": "object" }
12
+ }
13
+ }
@@ -0,0 +1,48 @@
1
+ require "json_schemer"
2
+ require "json"
3
+ module OpenExit
4
+ module Validation
5
+ module_function
6
+ def load_schema(name)
7
+ raise ArgumentError, "unknown PASP schema" unless SCHEMA_NAMES.include?(name)
8
+ JSON.parse(File.read(File.join(__dir__, "schemas", "#{name}.schema.json"), encoding: "UTF-8"))
9
+ end
10
+ def validate_manifest(manifest)
11
+ value = manifest.is_a?(Models::Manifest) ? manifest.raw : manifest
12
+ raise PaspError.new("PASP_INVALID_MANIFEST", "manifest must be an object") unless value.is_a?(Hash)
13
+ version = value["paspVersion"]
14
+ raise PaspError.new("PASP_UNSUPPORTED_VERSION", "unsupported PASP version: #{version}") if version && version != PASP_VERSION
15
+ required = %w[format paspVersion packageId exportId createdAt producer scope consistency resources assets integrity]
16
+ raise PaspError.new("PASP_INVALID_MANIFEST", "missing required manifest field") unless required.all? { |k| value.key?(k) }
17
+ raise PaspError.new("PASP_INVALID_MANIFEST", "invalid format") unless value["format"] == "openexit.bundle" && value["paspVersion"] == PASP_VERSION
18
+ raise PaspError.new("PASP_INVALID_MANIFEST", "invalid producer/scope/consistency") unless value["producer"].is_a?(Hash) && value["scope"].is_a?(Hash) && value["consistency"].is_a?(Hash)
19
+ raise PaspError.new("PASP_INVALID_MANIFEST", "invalid consistency") unless CONSISTENCY_LEVELS.include?(value.dig("consistency", "level"))
20
+ seen = {}
21
+ Array(value["resources"]).each do |entry|
22
+ name = entry.is_a?(Hash) && entry["name"]
23
+ raise PaspError.new("PASP_INVALID_RESOURCE", "invalid resource name") unless name.is_a?(String) && name.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
24
+ raise PaspError.new("PASP_DUPLICATE_RESOURCE", "duplicate resource: #{name}") if seen[name]
25
+ seen[name] = true
26
+ descriptor = entry["descriptor"]
27
+ Paths.validate_package_path(descriptor)
28
+ raise PaspError.new("PASP_INVALID_MANIFEST", "invalid descriptor") unless descriptor == "resources/#{name}/resource.json"
29
+ end
30
+ true
31
+ rescue PaspError
32
+ raise
33
+ rescue StandardError => e
34
+ raise PaspError.new("PASP_INVALID_MANIFEST", "invalid manifest", context: e)
35
+ end
36
+ def validate_document(name, value, code)
37
+ case name
38
+ when "resource"
39
+ raise PaspError.new(code, "invalid resource") unless value.is_a?(Hash) && value["name"].is_a?(String) && value["schema"] && value["identity"].is_a?(Array) && value["recordCount"].is_a?(Integer) && value["chunks"].is_a?(Array)
40
+ when "asset"
41
+ raise PaspError.new(code, "invalid asset") unless value.is_a?(Hash) && value["id"].is_a?(String) && value["path"].is_a?(String) && value["sha256"].to_s.match?(/\A[a-f0-9]{64}\z/) && value["byteLength"].is_a?(Integer)
42
+ when "relationship"
43
+ raise PaspError.new(code, "invalid relationship") unless value.is_a?(Hash) && value["id"].is_a?(String) && %w[one-to-one one-to-many many-to-one many-to-many reference].include?(value["cardinality"]) && %w[from to].all? { |k| value[k].is_a?(Hash) && value[k]["resource"].is_a?(String) && value[k]["pointer"].is_a?(String) }
44
+ end
45
+ true
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,4 @@
1
+ module OpenExit
2
+ VERSION = "0.1.0"
3
+ PASP_VERSION = "1.0"
4
+ end
data/lib/openexit.rb ADDED
@@ -0,0 +1,20 @@
1
+ require "pathname"
2
+ require_relative "openexit/version"
3
+ require_relative "openexit/constants"
4
+ require_relative "openexit/errors"
5
+ require_relative "openexit/models"
6
+ require_relative "openexit/paths"
7
+ require_relative "openexit/manifest"
8
+ require_relative "openexit/validation"
9
+ require_relative "openexit/integrity"
10
+ require_relative "openexit/bundle"
11
+
12
+ module OpenExit
13
+ class << self
14
+ def parse_manifest(source) = Manifest.parse(source)
15
+ def validate_manifest(manifest) = Validation.validate_manifest(manifest)
16
+ def inspect_bundle(path) = BundleOps.inspect_bundle(path, false)
17
+ def verify_bundle(path) = BundleOps.inspect_bundle(path, true)
18
+ def load_schema(name) = Validation.load_schema(name)
19
+ end
20
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: openexit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - OpenExit contributors
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json_schemer
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.4'
26
+ description: Ruby SDK for reading and verifying Portable Application State Protocol
27
+ directory bundles.
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - LICENSE
33
+ - README.md
34
+ - lib/openexit.rb
35
+ - lib/openexit/bundle.rb
36
+ - lib/openexit/constants.rb
37
+ - lib/openexit/errors.rb
38
+ - lib/openexit/integrity.rb
39
+ - lib/openexit/manifest.rb
40
+ - lib/openexit/models.rb
41
+ - lib/openexit/paths.rb
42
+ - lib/openexit/schemas/asset.schema.json
43
+ - lib/openexit/schemas/checkpoint.schema.json
44
+ - lib/openexit/schemas/event.schema.json
45
+ - lib/openexit/schemas/inspection-result.schema.json
46
+ - lib/openexit/schemas/manifest.schema.json
47
+ - lib/openexit/schemas/relationship.schema.json
48
+ - lib/openexit/schemas/resource-chunk.schema.json
49
+ - lib/openexit/schemas/resource.schema.json
50
+ - lib/openexit/schemas/scope.schema.json
51
+ - lib/openexit/validation.rb
52
+ - lib/openexit/version.rb
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ rubygems_mfa_required: 'true'
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '3.1'
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 4.0.20
72
+ specification_version: 4
73
+ summary: PASP 1.0 reader and verifier for Ruby
74
+ test_files: []