capsium 0.1.2 → 0.2.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +34 -7
  4. data/CHANGELOG.md +58 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE +21 -0
  7. data/README.adoc +177 -135
  8. data/Rakefile +8 -1
  9. data/bin/console +1 -1
  10. data/capsium.gemspec +11 -19
  11. data/exe/capsium +1 -1
  12. data/lib/capsium/cli/convert.rb +18 -0
  13. data/lib/capsium/cli/package.rb +86 -0
  14. data/lib/capsium/cli/reactor.rb +25 -0
  15. data/lib/capsium/cli.rb +3 -96
  16. data/lib/capsium/converters/jekyll.rb +5 -7
  17. data/lib/capsium/converters.rb +7 -0
  18. data/lib/capsium/package/dataset.rb +67 -34
  19. data/lib/capsium/package/manifest.rb +33 -22
  20. data/lib/capsium/package/manifest_config.rb +52 -7
  21. data/lib/capsium/package/metadata.rb +4 -8
  22. data/lib/capsium/package/metadata_config.rb +97 -9
  23. data/lib/capsium/package/routes.rb +39 -41
  24. data/lib/capsium/package/routes_config.rb +120 -44
  25. data/lib/capsium/package/security.rb +123 -0
  26. data/lib/capsium/package/security_config.rb +60 -0
  27. data/lib/capsium/package/storage.rb +20 -45
  28. data/lib/capsium/package/storage_config.rb +100 -3
  29. data/lib/capsium/package/validator.rb +149 -0
  30. data/lib/capsium/package.rb +68 -66
  31. data/lib/capsium/packager.rb +42 -6
  32. data/lib/capsium/reactor/introspection.rb +88 -0
  33. data/lib/capsium/reactor.rb +89 -41
  34. data/lib/capsium/version.rb +1 -1
  35. data/lib/capsium.rb +7 -9
  36. data/sig/capsium/package/dataset.rbs +28 -0
  37. data/sig/capsium/package/manifest.rbs +34 -0
  38. data/sig/capsium/package/manifest_config.rbs +38 -0
  39. data/sig/capsium/package/metadata.rbs +26 -0
  40. data/sig/capsium/package/metadata_config.rbs +49 -0
  41. data/sig/capsium/package/routes.rbs +37 -0
  42. data/sig/capsium/package/routes_config.rbs +74 -0
  43. data/sig/capsium/package/security.rbs +65 -0
  44. data/sig/capsium/package/security_config.rbs +57 -0
  45. data/sig/capsium/package/storage.rbs +28 -0
  46. data/sig/capsium/package/storage_config.rbs +61 -0
  47. data/sig/capsium/package/validator.rbs +29 -0
  48. data/sig/capsium/package.rbs +53 -0
  49. data/sig/capsium/packager.rbs +23 -0
  50. data/sig/capsium/reactor/introspection.rbs +31 -0
  51. data/sig/capsium/reactor.rbs +30 -0
  52. data/sig/capsium.rbs +3 -1
  53. metadata +52 -183
  54. data/lib/capsium/package/dataset_config.rb +0 -28
  55. data/lib/capsium/protector.rb +0 -103
@@ -1,17 +1,105 @@
1
- require "shale"
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
5
+ require "uri"
2
6
 
3
7
  module Capsium
4
8
  class Package
5
- class Dependency < Shale::Mapper
6
- attribute :name, Shale::Type::String
7
- attribute :version, Shale::Type::String
9
+ class Repository < Lutaml::Model::Serializable
10
+ attribute :type, :string
11
+ attribute :url, :string
12
+
13
+ json do
14
+ map :type, to: :type
15
+ map :url, to: :url
16
+ end
8
17
  end
9
18
 
10
- class MetadataData < Shale::Mapper
11
- attribute :name, Shale::Type::String
12
- attribute :version, Shale::Type::String
13
- attribute :description, Shale::Type::String # Add description attribute
14
- attribute :dependencies, Dependency, collection: true
19
+ # Canonical metadata.json model (ARCHITECTURE.md section 2).
20
+ #
21
+ # The legacy gem form of "dependencies" (an array of {name, version}
22
+ # objects) is accepted on read and normalized to the canonical object
23
+ # form; writers emit only the object form.
24
+ class MetadataData < Lutaml::Model::Serializable
25
+ KEBAB_CASE_PATTERN = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
26
+ SEMVER_PATTERN = /\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/
27
+ UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
28
+
29
+ attribute :name, :string
30
+ attribute :version, :string
31
+ attribute :description, :string
32
+ attribute :guid, :string
33
+ attribute :uuid, :string
34
+ attribute :author, :string
35
+ attribute :license, :string
36
+ attribute :repository, Repository
37
+ attribute :dependencies, :hash, default: {}
38
+ attribute :read_only, :boolean
39
+
40
+ json do
41
+ map :name, to: :name
42
+ map :version, to: :version
43
+ map :description, to: :description
44
+ map :guid, to: :guid
45
+ map :uuid, to: :uuid
46
+ map :author, to: :author
47
+ map :license, to: :license
48
+ map :repository, to: :repository
49
+ map :dependencies, to: :dependencies
50
+ map "readOnly", to: :read_only
51
+ end
52
+
53
+ def self.from_json(json)
54
+ doc = JSON.parse(json)
55
+ doc["dependencies"] = normalize_dependencies(doc["dependencies"])
56
+ super(JSON.generate(doc))
57
+ end
58
+
59
+ def self.normalize_dependencies(dependencies)
60
+ return {} if dependencies.nil?
61
+ return dependencies unless dependencies.is_a?(Array)
62
+
63
+ dependencies.to_h { |dep| [dep["name"], dep["version"]] }
64
+ end
65
+ private_class_method :normalize_dependencies
66
+
67
+ # Field-level format validations (ARCHITECTURE.md section 2).
68
+ # Returns a list of human-readable problems; empty when valid.
69
+ def format_errors
70
+ presence_errors + format_field_errors
71
+ end
72
+
73
+ private
74
+
75
+ def presence_errors
76
+ problems = []
77
+ problems << "name is missing" if name.to_s.empty?
78
+ problems << "version is missing" if version.to_s.empty?
79
+ problems << "description is missing" if description.to_s.empty?
80
+ problems << "guid is missing" if guid.to_s.empty?
81
+ problems << "uuid is missing" if uuid.to_s.empty?
82
+ problems
83
+ end
84
+
85
+ def format_field_errors
86
+ problems = []
87
+ problems << "name must be kebab-case" if invalid?(name, KEBAB_CASE_PATTERN)
88
+ problems << "version must be semver" if invalid?(version, SEMVER_PATTERN)
89
+ problems << "guid must be a URI" if guid && !uri?(guid)
90
+ problems << "uuid is not a valid UUID" if invalid?(uuid, UUID_PATTERN)
91
+ problems
92
+ end
93
+
94
+ def invalid?(value, pattern)
95
+ !value.nil? && !value.match?(pattern)
96
+ end
97
+
98
+ def uri?(value)
99
+ URI.parse(value).is_a?(URI::Generic)
100
+ rescue URI::InvalidURIError
101
+ false
102
+ end
15
103
  end
16
104
  end
17
105
  end
@@ -1,27 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package/routes.rb
4
- require "json"
5
- require "fileutils"
6
- require_relative "routes_config"
3
+ require "forwardable"
7
4
 
8
5
  module Capsium
9
6
  class Package
10
7
  class Routes
11
8
  extend Forwardable
12
- attr_reader :config
13
9
 
14
- def_delegators :@config, :to_json
10
+ attr_reader :path, :config, :manifest, :storage
15
11
 
16
- attr_reader :path, :config, :index, :manifest, :storage
12
+ def_delegators :@config, :to_hash
17
13
 
18
- ROUTES_FILE = "routes.json"
19
- DEFAULT_INDEX_TARGET = "content/index.html"
20
14
  INDEX_ROUTE = "/"
15
+ INDEX_RESOURCE = "content/index.html"
16
+ DATASET_ROUTE_PREFIX = "/api/v1/data/"
21
17
 
22
18
  def initialize(path, manifest, storage)
23
19
  @path = path
24
- @dir = File.dirname(path)
25
20
  @manifest = manifest
26
21
  @storage = storage
27
22
  @config = if File.exist?(path)
@@ -29,8 +24,6 @@ module Capsium
29
24
  else
30
25
  generate_routes
31
26
  end
32
- validate_index_path(@config.resolve(INDEX_ROUTE)&.target&.file)
33
- validate
34
27
  end
35
28
 
36
29
  def resolve(url_path)
@@ -38,12 +31,10 @@ module Capsium
38
31
  end
39
32
 
40
33
  def add_route(route, target)
41
- validate_route_target(route, target)
42
34
  @config.add(route, target)
43
35
  end
44
36
 
45
37
  def update_route(route, updated_route, updated_target)
46
- validate_route_target(updated_route, updated_target)
47
38
  @config.update(route, updated_route, updated_target)
48
39
  end
49
40
 
@@ -51,48 +42,55 @@ module Capsium
51
42
  @config.remove(route)
52
43
  end
53
44
 
54
- def validate
55
- @config.routes.each do |route|
56
- route.target.validate(@manifest, @storage)
57
- end
58
- end
59
-
60
45
  def to_json(*_args)
61
- @config.sort!.to_json
46
+ @config.to_json
62
47
  end
63
48
 
64
49
  def save_to_file(output_path = @path)
65
50
  File.write(output_path, to_json)
66
51
  end
67
52
 
53
+ # Auto-generation (ARCHITECTURE.md section 4): every manifest
54
+ # resource gets a route at its path relative to content/; HTML files
55
+ # get two routes (basename without extension and full filename); the
56
+ # index HTML additionally gets "/"; every dataset in storage gets
57
+ # /api/v1/data/<id>. Output is deterministic (sorted by path).
58
+ def generate_routes
59
+ routes = resource_routes + dataset_routes
60
+ RoutesConfig.new(index: index_resource, routes: routes.sort_by(&:path))
61
+ end
62
+
68
63
  private
69
64
 
70
- def generate_routes
71
- r = RoutesConfig.new
72
- manifest.config.sort!.content.each_with_object({}) do |data_item, _hash|
73
- relative_path = data_item.file.sub(/^#{Package::CONTENT_DIR}/o, "")
74
- r.add(relative_path, data_item.file)
75
-
76
- # Ensure the index route is included
77
- if File.basename(relative_path, ".*") == "index"
78
- r.add("/index", data_item.file)
79
- r.add("/", data_item.file)
80
- end
81
- end
65
+ def index_resource
66
+ INDEX_RESOURCE if @manifest.resources.key?(INDEX_RESOURCE)
67
+ end
82
68
 
83
- r
69
+ def resource_routes
70
+ @manifest.resources.keys.flat_map do |resource_path|
71
+ routes_for_resource(resource_path)
72
+ end.uniq(&:path)
84
73
  end
85
74
 
86
- def validate_index_path(index_path)
87
- return unless index_path
75
+ def routes_for_resource(resource_path)
76
+ url_path = resource_path.sub(%r{\A#{Package::CONTENT_DIR}/}o, "")
77
+ routes = [Route.new(path: "/#{url_path}", resource: resource_path)]
78
+ return routes unless File.extname(resource_path) == ".html"
88
79
 
89
- target_path = @manifest.path_to_content_file(index_path)
90
- raise "Index file does not exist: #{target_path}" unless File.exist?(target_path)
91
- raise "Index file is not an HTML file: #{target_path}" unless File.extname(target_path).downcase == ".html"
80
+ basename = File.basename(url_path, ".html")
81
+ routes << Route.new(path: "/#{basename}", resource: resource_path)
82
+ routes << Route.new(path: INDEX_ROUTE, resource: resource_path) if index?(resource_path)
83
+ routes
92
84
  end
93
85
 
94
- def validate_route_target(route, target)
95
- # Add any necessary validation logic for the route and target
86
+ def index?(resource_path)
87
+ resource_path == INDEX_RESOURCE
88
+ end
89
+
90
+ def dataset_routes
91
+ @storage.dataset_names.map do |name|
92
+ Route.new(path: "#{DATASET_ROUTE_PREFIX}#{name}", dataset: name)
93
+ end
96
94
  end
97
95
  end
98
96
  end
@@ -1,72 +1,148 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "shale"
3
+ require "json"
4
+ require "lutaml/model"
4
5
 
5
6
  module Capsium
6
7
  class Package
7
- class RouteTarget < Shale::Mapper
8
- attribute :file, Shale::Type::String
9
- attribute :dataset, Shale::Type::String
8
+ # A single route entry (ARCHITECTURE.md section 4). Kinds are
9
+ # discriminated by key, MECE:
10
+ # - {path, resource, headers?, visibility?} -- static file
11
+ # - {path, dataset, accessControl?} -- dataset route
12
+ # - {path, method, handler, ...} -- dynamic handler (parsed,
13
+ # accepted-and-ignored; reactors respond 501)
14
+ class Route < Lutaml::Model::Serializable
15
+ DATASET_PATH_PREFIX = "/api/v1/data/"
10
16
 
11
- def fs_path(manifest)
12
- return unless file
17
+ attribute :path, :string
18
+ attribute :resource, :string
19
+ attribute :headers, :hash
20
+ attribute :headers_file, :string
21
+ attribute :visibility, :string, values: Resource::VISIBILITIES
22
+ attribute :dataset, :string
23
+ attribute :access_control, :hash
24
+ attribute :http_method, :string
25
+ attribute :handler, :string
13
26
 
14
- manifest.path_to_content_file(manifest.lookup(file)&.file)
27
+ json do
28
+ map :path, to: :path
29
+ map :resource, to: :resource
30
+ map :headers, to: :headers
31
+ map "headersFile", to: :headers_file
32
+ map :visibility, to: :visibility
33
+ map :dataset, to: :dataset
34
+ map "accessControl", to: :access_control
35
+ map "method", to: :http_method
36
+ map :handler, to: :handler
37
+ end
38
+
39
+ def kind
40
+ return :resource if resource
41
+ return :dataset if dataset
42
+
43
+ :handler
44
+ end
45
+
46
+ def dataset_route?
47
+ kind == :dataset
48
+ end
49
+
50
+ def handler_route?
51
+ kind == :handler
52
+ end
53
+
54
+ def fs_path(package_path)
55
+ return unless resource
56
+
57
+ File.join(package_path, resource)
15
58
  end
16
59
 
17
60
  def mime(manifest)
18
- manifest.lookup(file)&.mime
19
- end
20
-
21
- def validate(manifest, storage)
22
- if file
23
- target_path = fs_path(manifest)
24
- unless target_path && File.exist?(target_path) && target_path.to_s.start_with?(manifest.content_path.to_s)
25
- raise "Route target does not exist or is outside of the content directory: #{target_path}"
26
- end
27
- elsif dataset
28
- unless storage.datasets.any? { |ds| ds.config.name == dataset }
29
- raise "Dataset target does not exist: #{dataset}"
30
- end
31
- else
32
- raise "Route target must have either a file or a dataset"
61
+ manifest.type_for(resource)
62
+ end
63
+
64
+ def validate_target(package_path, storage)
65
+ return if handler_route?
66
+
67
+ if dataset_route?
68
+ return if storage.dataset(dataset)
69
+
70
+ raise Error, "Route dataset does not exist: #{dataset}"
33
71
  end
72
+ validate_resource_target(package_path)
34
73
  end
35
- end
36
74
 
37
- class Route < Shale::Mapper
38
- attribute :path, Shale::Type::String
39
- attribute :target, RouteTarget
75
+ private
76
+
77
+ def validate_resource_target(package_path)
78
+ target_path = fs_path(package_path)
79
+ return if target_path && File.exist?(target_path)
80
+
81
+ raise Error, "Route resource does not exist: #{resource}"
82
+ end
40
83
  end
41
84
 
42
- class RoutesConfig < Shale::Mapper
43
- attribute :routes, Route, collection: true
85
+ # Canonical routes.json model: optional top-level "index" plus a
86
+ # "routes" array. The legacy gem form ({path, target: {file|dataset}})
87
+ # is accepted on read and normalized; writers emit only the canonical
88
+ # form.
89
+ class RoutesConfig < Lutaml::Model::Serializable
90
+ attribute :index, :string
91
+ attribute :routes, Route, collection: true, default: []
92
+
93
+ json do
94
+ map :index, to: :index
95
+ map :routes, with: { from: :routes_from_json, to: :routes_to_json }
96
+ end
97
+
98
+ def self.from_json(json)
99
+ doc = JSON.parse(json)
100
+ doc["routes"] = (doc["routes"] || []).map { |route| normalize_route(route) }
101
+ super(JSON.generate(doc))
102
+ end
103
+
104
+ def self.normalize_route(route)
105
+ target = route.delete("target")
106
+ return route unless target.is_a?(Hash)
107
+
108
+ route.merge("resource" => target["file"], "dataset" => target["dataset"]).compact
109
+ end
110
+ private_class_method :normalize_route
111
+
112
+ def routes_from_json(model, value)
113
+ model.routes = (value || []).map { |route| Route.from_json(JSON.generate(route)) }
114
+ end
115
+
116
+ # Writers emit the canonical form only: deterministic, sorted by path.
117
+ def routes_to_json(model, doc)
118
+ doc["routes"] = model.routes.sort_by(&:path).map do |route|
119
+ JSON.parse(route.to_json)
120
+ end
121
+ end
44
122
 
45
- def resolve(route)
46
- routes.detect { |r| r.path == route }
123
+ def resolve(path)
124
+ routes.detect { |route| route.path == path }
47
125
  end
48
126
 
49
- def add(route, target)
50
- target = RouteTarget.new(file: target) if target.is_a?(String)
51
- r = Route.new(path: route, target: target)
52
- @routes << r
53
- r
127
+ def add(path, target)
128
+ route = Route.new(path: path, resource: target)
129
+ self.routes = routes + [route]
130
+ route
54
131
  end
55
132
 
56
- def update(route, updated_route, updated_target)
57
- r = resolve(route)
58
- r.path = updated_route
59
- r.target = updated_target
60
- r
133
+ def update(path, updated_path, updated_target)
134
+ route = resolve(path)
135
+ route.path = updated_path
136
+ route.resource = updated_target
137
+ route
61
138
  end
62
139
 
63
- def remove(route)
64
- r = resolve(route)
65
- @routes.delete(r)
140
+ def remove(path)
141
+ self.routes = routes.reject { |route| route.path == path }
66
142
  end
67
143
 
68
144
  def sort!
69
- @routes.sort_by!(&:path)
145
+ self.routes = routes.sort_by(&:path)
70
146
  self
71
147
  end
72
148
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "forwardable"
5
+ require "json"
6
+
7
+ module Capsium
8
+ class Package
9
+ # Loads, generates and verifies security.json (ARCHITECTURE.md
10
+ # section 6). Checksums cover every file in the package except
11
+ # security.json itself.
12
+ class Security
13
+ extend Forwardable
14
+
15
+ class IntegrityError < Capsium::Error; end
16
+
17
+ ChecksumMismatch = Data.define(:path, :expected, :actual) do
18
+ def message
19
+ "checksum mismatch: #{path}"
20
+ end
21
+ end
22
+ MissingFile = Data.define(:path, :expected) do
23
+ def message
24
+ "checksum listed but file missing: #{path}"
25
+ end
26
+ end
27
+ UncheckedFile = Data.define(:path) do
28
+ def message
29
+ "file not covered by checksums: #{path}"
30
+ end
31
+ end
32
+
33
+ attr_reader :path, :config
34
+
35
+ def_delegators :@config, :to_json, :to_hash
36
+
37
+ def initialize(path, config = nil)
38
+ @path = path
39
+ @config = config || load_config
40
+ end
41
+
42
+ def self.generate(package_path)
43
+ config = SecurityConfig.new(
44
+ security: SecurityData.new(
45
+ integrity_checks: IntegrityChecks.new(
46
+ checksum_algorithm: "SHA-256",
47
+ checksums: checksums_for(package_path)
48
+ )
49
+ )
50
+ )
51
+ new(File.join(package_path, SECURITY_FILE), config)
52
+ end
53
+
54
+ def self.checksums_for(package_path)
55
+ package_files(package_path).sort.to_h do |relative_path|
56
+ [relative_path, Digest::SHA256.file(File.join(package_path, relative_path)).hexdigest]
57
+ end
58
+ end
59
+
60
+ def self.package_files(package_path)
61
+ files = Dir.glob(File.join(package_path, "**", "*")).select do |file|
62
+ File.file?(file)
63
+ end
64
+ files.map { |file| file.delete_prefix("#{package_path}/") }
65
+ .reject { |relative_path| relative_path == SECURITY_FILE }
66
+ end
67
+
68
+ def present?
69
+ !@config.nil?
70
+ end
71
+
72
+ def checksums
73
+ present? ? config.checksums : {}
74
+ end
75
+
76
+ def verify(package_path)
77
+ expected = checksums
78
+ errors = verify_present_files(package_path, expected)
79
+ expected.each do |relative_path, checksum|
80
+ next if File.file?(File.join(package_path, relative_path))
81
+
82
+ errors << MissingFile.new(path: relative_path, expected: checksum)
83
+ end
84
+ errors
85
+ end
86
+
87
+ def verify!(package_path)
88
+ errors = verify(package_path)
89
+ return if errors.empty?
90
+
91
+ raise IntegrityError,
92
+ "Package integrity check failed: #{errors.map(&:path).join(', ')}"
93
+ end
94
+
95
+ def save_to_file(output_path = @path)
96
+ File.write(output_path, to_json)
97
+ end
98
+
99
+ private
100
+
101
+ def load_config
102
+ return unless File.exist?(@path)
103
+
104
+ SecurityConfig.from_json(File.read(@path))
105
+ end
106
+
107
+ def verify_present_files(package_path, expected)
108
+ self.class.package_files(package_path).filter_map do |relative_path|
109
+ file_path = File.join(package_path, relative_path)
110
+ if expected.key?(relative_path)
111
+ actual = Digest::SHA256.file(file_path).hexdigest
112
+ unless actual == expected[relative_path]
113
+ next ChecksumMismatch.new(path: relative_path, expected: expected[relative_path],
114
+ actual: actual)
115
+ end
116
+ else
117
+ UncheckedFile.new(path: relative_path)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Capsium
6
+ class Package
7
+ # The "integrityChecks" object of security.json (ARCHITECTURE.md
8
+ # section 6): SHA-256 checksums over every package file except
9
+ # security.json itself, keyed by package-relative path.
10
+ class IntegrityChecks < Lutaml::Model::Serializable
11
+ ALGORITHMS = %w[SHA-256].freeze
12
+
13
+ attribute :checksum_algorithm, :string, values: ALGORITHMS, default: "SHA-256"
14
+ attribute :checksums, :hash, default: {}
15
+
16
+ json do
17
+ map "checksumAlgorithm", to: :checksum_algorithm
18
+ map :checksums, to: :checksums
19
+ end
20
+ end
21
+
22
+ # The "digitalSignatures" object of security.json. Signing is a later
23
+ # phase; the model parses the canonical fields only.
24
+ class DigitalSignatures < Lutaml::Model::Serializable
25
+ attribute :public_key, :string
26
+ attribute :signature_file, :string
27
+
28
+ json do
29
+ map "publicKey", to: :public_key
30
+ map "signatureFile", to: :signature_file
31
+ end
32
+ end
33
+
34
+ # The "security" object of security.json.
35
+ class SecurityData < Lutaml::Model::Serializable
36
+ attribute :integrity_checks, IntegrityChecks
37
+ attribute :digital_signatures, DigitalSignatures
38
+
39
+ json do
40
+ map "integrityChecks", to: :integrity_checks
41
+ map "digitalSignatures", to: :digital_signatures
42
+ end
43
+ end
44
+
45
+ # Canonical security.json model (generated at pack time).
46
+ class SecurityConfig < Lutaml::Model::Serializable
47
+ attribute :security, SecurityData
48
+
49
+ json do
50
+ map :security, to: :security
51
+ end
52
+
53
+ def checksums
54
+ return {} unless security&.integrity_checks
55
+
56
+ security.integrity_checks.checksums
57
+ end
58
+ end
59
+ end
60
+ end