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 +7 -0
- data/LICENSE +21 -0
- data/README.md +8 -0
- data/lib/openexit/bundle.rb +98 -0
- data/lib/openexit/constants.rb +4 -0
- data/lib/openexit/errors.rb +11 -0
- data/lib/openexit/integrity.rb +20 -0
- data/lib/openexit/manifest.rb +17 -0
- data/lib/openexit/models.rb +15 -0
- data/lib/openexit/paths.rb +23 -0
- data/lib/openexit/schemas/asset.schema.json +17 -0
- data/lib/openexit/schemas/checkpoint.schema.json +12 -0
- data/lib/openexit/schemas/event.schema.json +25 -0
- data/lib/openexit/schemas/inspection-result.schema.json +15 -0
- data/lib/openexit/schemas/manifest.schema.json +22 -0
- data/lib/openexit/schemas/relationship.schema.json +26 -0
- data/lib/openexit/schemas/resource-chunk.schema.json +20 -0
- data/lib/openexit/schemas/resource.schema.json +21 -0
- data/lib/openexit/schemas/scope.schema.json +13 -0
- data/lib/openexit/validation.rb +48 -0
- data/lib/openexit/version.rb +4 -0
- data/lib/openexit.rb +20 -0
- metadata +74 -0
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,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,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
|
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: []
|