capsium 0.1.1 → 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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +1 -4
  3. data/.github/workflows/release.yml +1 -2
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +34 -3
  6. data/CHANGELOG.md +58 -0
  7. data/Gemfile +4 -4
  8. data/LICENSE +21 -0
  9. data/README.adoc +177 -135
  10. data/Rakefile +8 -1
  11. data/bin/console +1 -1
  12. data/capsium.gemspec +16 -23
  13. data/exe/capsium +1 -1
  14. data/lib/capsium/cli/convert.rb +18 -0
  15. data/lib/capsium/cli/package.rb +86 -0
  16. data/lib/capsium/cli/reactor.rb +25 -0
  17. data/lib/capsium/cli.rb +5 -79
  18. data/lib/capsium/converters/jekyll.rb +62 -31
  19. data/lib/capsium/converters.rb +7 -0
  20. data/lib/capsium/package/dataset.rb +76 -56
  21. data/lib/capsium/package/manifest.rb +41 -50
  22. data/lib/capsium/package/manifest_config.rb +64 -0
  23. data/lib/capsium/package/metadata.rb +11 -29
  24. data/lib/capsium/package/metadata_config.rb +105 -0
  25. data/lib/capsium/package/routes.rb +51 -111
  26. data/lib/capsium/package/routes_config.rb +150 -0
  27. data/lib/capsium/package/security.rb +123 -0
  28. data/lib/capsium/package/security_config.rb +60 -0
  29. data/lib/capsium/package/storage.rb +26 -26
  30. data/lib/capsium/package/storage_config.rb +106 -0
  31. data/lib/capsium/package/validator.rb +149 -0
  32. data/lib/capsium/package.rb +84 -70
  33. data/lib/capsium/packager.rb +60 -62
  34. data/lib/capsium/reactor/introspection.rb +88 -0
  35. data/lib/capsium/reactor.rb +108 -36
  36. data/lib/capsium/version.rb +1 -1
  37. data/lib/capsium.rb +7 -9
  38. data/sig/capsium/package/dataset.rbs +28 -0
  39. data/sig/capsium/package/manifest.rbs +34 -0
  40. data/sig/capsium/package/manifest_config.rbs +38 -0
  41. data/sig/capsium/package/metadata.rbs +26 -0
  42. data/sig/capsium/package/metadata_config.rbs +49 -0
  43. data/sig/capsium/package/routes.rbs +37 -0
  44. data/sig/capsium/package/routes_config.rbs +74 -0
  45. data/sig/capsium/package/security.rbs +65 -0
  46. data/sig/capsium/package/security_config.rbs +57 -0
  47. data/sig/capsium/package/storage.rbs +28 -0
  48. data/sig/capsium/package/storage_config.rbs +61 -0
  49. data/sig/capsium/package/validator.rbs +29 -0
  50. data/sig/capsium/package.rbs +53 -0
  51. data/sig/capsium/packager.rbs +23 -0
  52. data/sig/capsium/reactor/introspection.rbs +31 -0
  53. data/sig/capsium/reactor.rbs +30 -0
  54. data/sig/capsium.rbs +3 -1
  55. metadata +63 -146
  56. data/images/005df7415a331c466ad2d9a42efdd212b6f42fe50b5fd3b3174c86c706f58244.png +0 -0
  57. data/images/0374025b3af99b8a473282c8cbbf9fcd29573cf41e586982f328f86c0690f43d.png +0 -0
  58. data/images/0bb4da785be40ef58e219470ebccb979325928b75453dc46bac23c6ee8a7a7cb.png +0 -0
  59. data/images/6aa294dccc81af594aacbe804e7ddffdc17eacc28357338108aea5d021d831ff.png +0 -0
  60. data/images/72dd3fbf3f4b475e27a0e7fb8137c475c32c41f8d222bcf62d6a9ccf102d9532.png +0 -0
  61. data/images/8772b6961d169738d7b0fa0b669b06fc2f40632d4c62586c7634fc17b93182a3.png +0 -0
  62. data/images/a998d842405933d45723606ff3f70162ec95b4ef30db25464a366184fd08fb9b.png +0 -0
  63. data/images/aa8980547e8c003d33273ab4d80e62da7f317bd7581b293c06d67f5331f24f31.png +0 -0
  64. data/images/bb78a872b539e0e9b2d80dee58acbb688f3f2727b324a5bf8bf417a69d94a166.png +0 -0
  65. data/images/c48fc83b17725d85fbb64d971196ebfccd8c5c757fe6aa5845303f6e315879b6.png +0 -0
  66. data/images/f08ef07308d08119ac2124bb7428c8bef17ef1ca70045696604d6e83015a9b91.png +0 -0
  67. data/images/f7514206111b695647eae9adfcf498ba3e0ff83ecfe25f3fc3ed8e9f04c5c726-1.png +0 -0
  68. data/images/f7514206111b695647eae9adfcf498ba3e0ff83ecfe25f3fc3ed8e9f04c5c726.png +0 -0
  69. data/lib/capsium/protector.rb +0 -95
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
5
+
6
+ module Capsium
7
+ class Package
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/"
16
+
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
26
+
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)
58
+ end
59
+
60
+ def mime(manifest)
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}"
71
+ end
72
+ validate_resource_target(package_path)
73
+ end
74
+
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
83
+ end
84
+
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
122
+
123
+ def resolve(path)
124
+ routes.detect { |route| route.path == path }
125
+ end
126
+
127
+ def add(path, target)
128
+ route = Route.new(path: path, resource: target)
129
+ self.routes = routes + [route]
130
+ route
131
+ end
132
+
133
+ def update(path, updated_path, updated_target)
134
+ route = resolve(path)
135
+ route.path = updated_path
136
+ route.resource = updated_target
137
+ route
138
+ end
139
+
140
+ def remove(path)
141
+ self.routes = routes.reject { |route| route.path == path }
142
+ end
143
+
144
+ def sort!
145
+ self.routes = routes.sort_by(&:path)
146
+ self
147
+ end
148
+ end
149
+ end
150
+ 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
@@ -1,51 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package/storage.rb
4
- require "json"
3
+ require "forwardable"
5
4
 
6
5
  module Capsium
7
6
  class Package
8
7
  class Storage
9
- attr_reader :datasets
8
+ extend Forwardable
10
9
 
11
- DATA_DIR = "data"
10
+ attr_reader :path, :config, :datasets
11
+
12
+ def_delegators :@config, :to_json, :to_hash
12
13
 
13
14
  def initialize(path)
14
15
  @path = path
15
16
  @dir = File.dirname(path)
16
- @datasets_path = File.join(@dir, DATA_DIR)
17
- @datasets = load_datasets || generate_datasets
17
+ @config = if File.exist?(path)
18
+ StorageConfig.from_json(File.read(path))
19
+ else
20
+ StorageConfig.new
21
+ end
22
+ @datasets = load_datasets
18
23
  end
19
24
 
20
- def load_datasets
21
- return unless File.exist?(@path)
25
+ def data_sets
26
+ @config.data_sets
27
+ end
22
28
 
23
- storage_data = JSON.parse(File.read(@path))
24
- @datasets = storage_data["storage"]
29
+ def load_datasets
30
+ data_sets.map do |name, dataset_config|
31
+ dataset_config.to_dataset(name, @dir)
32
+ end
25
33
  end
26
34
 
27
- def as_json
28
- { datasets: datasets.map(&:as_json) }
35
+ def dataset(name)
36
+ @datasets.find { |dataset| dataset.name == name }
29
37
  end
30
38
 
31
- def to_json(*_args)
32
- JSON.pretty_generate(as_json)
39
+ def dataset_names
40
+ data_sets.keys.sort
33
41
  end
34
42
 
35
- def save_to_file(output_path = @path)
36
- File.open(output_path, "w") do |file|
37
- file.write(to_json)
38
- end
43
+ def empty?
44
+ data_sets.empty?
39
45
  end
40
46
 
41
- def generate_datasets
42
- datasets = []
43
- paths = File.join(@datasets_path, "*.{yaml,yml,json,csv,tsv,sqlite,db}")
44
- Dir.glob(paths).each do |file_path|
45
- datasets << Dataset.new(file_path, @datasets_path)
46
- # dataset_info[:table] = dataset.table_name if dataset.type == :sqlite
47
- end
48
- datasets
47
+ def save_to_file(output_path = @path)
48
+ File.write(output_path, to_json)
49
49
  end
50
50
  end
51
51
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
5
+
6
+ module Capsium
7
+ class Package
8
+ # A single dataset entry (ARCHITECTURE.md section 5). Paths are
9
+ # package-relative POSIX paths. Kinds: schema-backed file (via
10
+ # "source") or SQLite ("databaseFile" + "table").
11
+ class DatasetConfig < Lutaml::Model::Serializable
12
+ FORMATS = {
13
+ ".yaml" => "yaml", ".yml" => "yaml", ".json" => "json",
14
+ ".csv" => "csv", ".tsv" => "tsv",
15
+ ".sqlite" => "sqlite", ".db" => "sqlite"
16
+ }.freeze
17
+ SCHEMA_TYPES = %w[json-schema].freeze
18
+
19
+ attribute :source, :string
20
+ attribute :schema_file, :string
21
+ attribute :schema_type, :string, values: SCHEMA_TYPES
22
+ attribute :database_file, :string
23
+ attribute :table, :string
24
+
25
+ json do
26
+ map :source, to: :source
27
+ map "schemaFile", to: :schema_file
28
+ map "schemaType", to: :schema_type
29
+ map "databaseFile", to: :database_file
30
+ map :table, to: :table
31
+ end
32
+
33
+ def format
34
+ FORMATS.fetch(File.extname(backing_file).downcase) do
35
+ raise Error, "Unsupported data file type: #{File.extname(backing_file)}"
36
+ end
37
+ end
38
+
39
+ def sqlite?
40
+ !database_file.nil?
41
+ end
42
+
43
+ def backing_file
44
+ database_file || source.to_s
45
+ end
46
+
47
+ def to_dataset(name, package_path)
48
+ Dataset.new(name: name, config: self, package_path: package_path)
49
+ end
50
+ end
51
+
52
+ # The "storage" object holding the dataSets map.
53
+ class StorageData < Lutaml::Model::Serializable
54
+ attribute :data_sets, :hash, default: {}
55
+
56
+ json do
57
+ map "dataSets", with: { from: :data_sets_from_json, to: :data_sets_to_json }
58
+ end
59
+
60
+ def data_sets_from_json(model, value)
61
+ model.data_sets = (value || {}).to_h do |name, attributes|
62
+ [name, DatasetConfig.from_json(JSON.generate(attributes))]
63
+ end
64
+ end
65
+
66
+ def data_sets_to_json(model, doc)
67
+ doc["dataSets"] = model.data_sets.sort.to_h do |name, dataset|
68
+ [name, JSON.parse(dataset.to_json)]
69
+ end
70
+ end
71
+ end
72
+
73
+ # Canonical storage.json model. The legacy gem form
74
+ # ({"datasets": [{name, source, format, schema}]}) is accepted on read
75
+ # and normalized; writers emit only the canonical form.
76
+ class StorageConfig < Lutaml::Model::Serializable
77
+ attribute :storage, StorageData
78
+
79
+ json do
80
+ map :storage, to: :storage
81
+ end
82
+
83
+ def self.from_json(json)
84
+ doc = JSON.parse(json)
85
+ doc["storage"] ||= { "dataSets" => legacy_data_sets(doc.delete("datasets")) }
86
+ super(JSON.generate(doc))
87
+ end
88
+
89
+ def self.legacy_data_sets(datasets)
90
+ (datasets || []).to_h do |item|
91
+ entry = { "source" => item["source"] }
92
+ if item["schema"]
93
+ entry["schemaFile"] = item["schema"]
94
+ entry["schemaType"] = "json-schema"
95
+ end
96
+ [item["name"], entry]
97
+ end
98
+ end
99
+ private_class_method :legacy_data_sets
100
+
101
+ def data_sets
102
+ storage ? storage.data_sets : {}
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "marcel"
5
+ require "pathname"
6
+ require "tmpdir"
7
+
8
+ module Capsium
9
+ class Package
10
+ # Validates a package directory or .cap file against the canonical
11
+ # schemas (ARCHITECTURE.md sections 2-6) and reports one result per
12
+ # check. Used by `capsium package validate`.
13
+ class Validator
14
+ CheckResult = Data.define(:name, :ok, :messages) do
15
+ def ok?
16
+ ok
17
+ end
18
+ end
19
+
20
+ EXTERNAL_REFERENCE_PATTERN = %r{(?:src|href)\s*=\s*["']https?://}in
21
+ TEXT_CONTENT_MIME = %r{\Atext/|application/(json|javascript|xml)|image/svg\+xml}
22
+
23
+ def initialize(package_path)
24
+ @given_path = package_path
25
+ end
26
+
27
+ def run
28
+ Dir.mktmpdir do |dir|
29
+ @package_path = prepare(dir)
30
+ [metadata_check, manifest_check, routes_check,
31
+ storage_check, security_check, content_check]
32
+ end
33
+ end
34
+
35
+ def valid?
36
+ run.all?(&:ok?)
37
+ end
38
+
39
+ private
40
+
41
+ def prepare(dir)
42
+ return @given_path if File.directory?(@given_path)
43
+
44
+ destination = File.join(dir, "package")
45
+ Packager.new.unpack(@given_path, destination)
46
+ destination
47
+ end
48
+
49
+ def metadata_check
50
+ path = metadata_path(METADATA_FILE)
51
+ return failure("metadata", ["metadata.json is missing"]) unless File.exist?(path)
52
+
53
+ result("metadata", Metadata.new(path).config.format_errors)
54
+ rescue StandardError => e
55
+ failure("metadata", ["metadata.json is not valid: #{e.message}"])
56
+ end
57
+
58
+ def manifest_check
59
+ manifest = Manifest.new(metadata_path(MANIFEST_FILE))
60
+ missing = manifest.resources.keys.reject do |path|
61
+ File.file?(File.join(@package_path, path))
62
+ end
63
+ result("manifest", missing.map { |path| "resource missing on disk: #{path}" })
64
+ rescue StandardError => e
65
+ failure("manifest", ["manifest.json is not valid: #{e.message}"])
66
+ end
67
+
68
+ def routes_check
69
+ manifest = Manifest.new(metadata_path(MANIFEST_FILE))
70
+ storage = Storage.new(metadata_path(STORAGE_FILE))
71
+ routes = Routes.new(metadata_path(ROUTES_FILE), manifest, storage)
72
+ problems = route_problems(routes, storage) + index_problems(routes)
73
+ result("routes", problems)
74
+ rescue StandardError => e
75
+ failure("routes", ["routes.json is not valid: #{e.message}"])
76
+ end
77
+
78
+ def route_problems(routes, storage)
79
+ routes.config.routes.flat_map do |route|
80
+ route_target_problems(route, storage)
81
+ end
82
+ end
83
+
84
+ def route_target_problems(route, storage)
85
+ problems = []
86
+ if route.dataset_route? && !route.path.start_with?(Route::DATASET_PATH_PREFIX)
87
+ problems << "dataset route #{route.path} not under #{Route::DATASET_PATH_PREFIX}"
88
+ end
89
+ route.validate_target(@package_path, storage)
90
+ problems
91
+ rescue Error => e
92
+ problems + [e.message]
93
+ end
94
+
95
+ def index_problems(routes)
96
+ index = routes.config.index
97
+ return ["index route is missing"] if index.nil?
98
+ unless File.file?(File.join(@package_path, index))
99
+ return ["index missing on disk: #{index}"]
100
+ end
101
+ return [] if File.extname(index).downcase == ".html"
102
+
103
+ ["index is not an HTML file: #{index}"]
104
+ end
105
+
106
+ def storage_check
107
+ storage = Storage.new(metadata_path(STORAGE_FILE))
108
+ problems = storage.datasets.flat_map(&:validation_errors)
109
+ result("storage", problems)
110
+ rescue StandardError => e
111
+ failure("storage", ["storage.json is not valid: #{e.message}"])
112
+ end
113
+
114
+ def security_check
115
+ security = Security.new(metadata_path(SECURITY_FILE))
116
+ return result("security", []) unless security.present?
117
+
118
+ result("security", security.verify(@package_path).map(&:message))
119
+ end
120
+
121
+ def content_check
122
+ offenders = Dir.glob(File.join(@package_path, CONTENT_DIR, "**", "*")).select do |file|
123
+ File.file?(file) && text_file?(file) &&
124
+ File.binread(file).match?(EXTERNAL_REFERENCE_PATTERN)
125
+ end
126
+ result("content", offenders.map do |file|
127
+ "external reference in #{file.delete_prefix("#{@package_path}/")}"
128
+ end)
129
+ end
130
+
131
+ def text_file?(path)
132
+ mime = Marcel::MimeType.for(Pathname.new(path), name: File.basename(path))
133
+ mime.match?(TEXT_CONTENT_MIME)
134
+ end
135
+
136
+ def metadata_path(file_name)
137
+ File.join(@package_path, file_name)
138
+ end
139
+
140
+ def result(name, problems)
141
+ CheckResult.new(name: name, ok: problems.empty?, messages: problems)
142
+ end
143
+
144
+ def failure(name, messages)
145
+ CheckResult.new(name: name, ok: false, messages: messages)
146
+ end
147
+ end
148
+ end
149
+ end