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,76 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package/storage.rb
4
- require "json"
5
- require "shale"
6
- require_relative "dataset"
7
- require_relative "storage_config"
3
+ require "forwardable"
8
4
 
9
5
  module Capsium
10
6
  class Package
11
7
  class Storage
12
8
  extend Forwardable
13
- attr_reader :config
14
9
 
15
- def_delegators :@config, :to_json
10
+ attr_reader :path, :config, :datasets
16
11
 
17
- attr_reader :datasets
12
+ def_delegators :@config, :to_json, :to_hash
18
13
 
19
14
  def initialize(path)
20
15
  @path = path
21
16
  @dir = File.dirname(path)
22
- @datasets_path = File.join(@dir, DATA_DIR)
23
17
  @config = if File.exist?(path)
24
18
  StorageConfig.from_json(File.read(path))
25
19
  else
26
- StorageConfig.new(datasets: generate_datasets)
20
+ StorageConfig.new
27
21
  end
28
- @datasets = load_datasets || generate_datasets
22
+ @datasets = load_datasets
23
+ end
24
+
25
+ def data_sets
26
+ @config.data_sets
29
27
  end
30
28
 
31
29
  def load_datasets
32
- if File.exist?(@path)
33
- storage_data = StorageConfig.from_json(File.read(@path))
34
- storage_data.datasets.map do |dataset_config|
35
- dataset_config.to_dataset(@datasets_path)
36
- end
30
+ data_sets.map do |name, dataset_config|
31
+ dataset_config.to_dataset(name, @dir)
37
32
  end
38
33
  end
39
34
 
40
- def save_to_file(output_path = @path)
41
- storage_config = StorageConfig.new(datasets: @datasets.map do |dataset|
42
- DatasetConfig.from_dataset(dataset)
43
- end)
44
- File.write(output_path, storage_config.to_json)
35
+ def dataset(name)
36
+ @datasets.find { |dataset| dataset.name == name }
45
37
  end
46
38
 
47
- def generate_datasets
48
- paths = File.join(@datasets_path, "*.{yaml,yml,json,csv,tsv,sqlite,db}")
49
- Dir.glob(paths).map do |file_path|
50
- Dataset.new(config: DatasetConfig.new(
51
- name: File.basename(file_path,
52
- ".*"), source: file_path, format: detect_format(file_path)
53
- ))
54
- end
39
+ def dataset_names
40
+ data_sets.keys.sort
55
41
  end
56
42
 
57
- private
43
+ def empty?
44
+ data_sets.empty?
45
+ end
58
46
 
59
- def detect_format(file_path)
60
- case File.extname(file_path).downcase
61
- when ".yaml", ".yml"
62
- "yaml"
63
- when ".json"
64
- "json"
65
- when ".csv"
66
- "csv"
67
- when ".tsv"
68
- "tsv"
69
- when ".sqlite", ".db"
70
- "sqlite"
71
- else
72
- raise "Unsupported data file type: #{File.extname(file_path)}"
73
- end
47
+ def save_to_file(output_path = @path)
48
+ File.write(output_path, to_json)
74
49
  end
75
50
  end
76
51
  end
@@ -1,9 +1,106 @@
1
- require "shale"
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
2
5
 
3
6
  module Capsium
4
7
  class Package
5
- class StorageConfig < Shale::Mapper
6
- attribute :datasets, DatasetConfig, collection: true
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
7
104
  end
8
105
  end
9
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
@@ -1,41 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package.rb
4
3
  require "fileutils"
5
- require "json"
6
- require "yaml"
7
- require "csv"
8
- require "sqlite3"
9
- require "zip"
10
- require_relative "package/manifest"
11
- require_relative "package/metadata"
12
- require_relative "package/routes"
13
- require_relative "package/dataset"
14
- require_relative "package/storage"
15
- require_relative "packager"
4
+ require "pathname"
5
+ require "tmpdir"
16
6
 
17
7
  module Capsium
18
8
  class Package
19
- attr_reader :name, :path, :manifest, :metadata, :routes, :datasets,
20
- :storage, :load_type
9
+ autoload :Dataset, "capsium/package/dataset"
10
+ autoload :DatasetConfig, "capsium/package/storage_config"
11
+ autoload :DigitalSignatures, "capsium/package/security_config"
12
+ autoload :IntegrityChecks, "capsium/package/security_config"
13
+ autoload :Manifest, "capsium/package/manifest"
14
+ autoload :ManifestConfig, "capsium/package/manifest_config"
15
+ autoload :Metadata, "capsium/package/metadata"
16
+ autoload :MetadataData, "capsium/package/metadata_config"
17
+ autoload :Repository, "capsium/package/metadata_config"
18
+ autoload :Resource, "capsium/package/manifest_config"
19
+ autoload :Route, "capsium/package/routes_config"
20
+ autoload :Routes, "capsium/package/routes"
21
+ autoload :RoutesConfig, "capsium/package/routes_config"
22
+ autoload :Security, "capsium/package/security"
23
+ autoload :SecurityConfig, "capsium/package/security_config"
24
+ autoload :SecurityData, "capsium/package/security_config"
25
+ autoload :Storage, "capsium/package/storage"
26
+ autoload :StorageConfig, "capsium/package/storage_config"
27
+ autoload :StorageData, "capsium/package/storage_config"
28
+ autoload :Validator, "capsium/package/validator"
29
+
30
+ attr_reader :name, :path, :manifest, :metadata, :routes, :storage,
31
+ :security, :load_type
21
32
 
22
33
  MANIFEST_FILE = "manifest.json"
23
34
  METADATA_FILE = "metadata.json"
24
- PACKAGING_FILE = "packaging.json"
25
- SIGNATURE_FILE = "signature.json"
26
35
  STORAGE_FILE = "storage.json"
27
36
  ROUTES_FILE = "routes.json"
37
+ SECURITY_FILE = "security.json"
28
38
  CONTENT_DIR = "content"
29
39
  DATA_DIR = "data"
30
- ENCRYPTED_PACKAGING_FILE = "package.enc"
31
40
 
32
41
  def initialize(path, load_type: nil)
33
42
  @original_path = Pathname.new(path).expand_path
34
- @path = prepare_package(@original_path)
43
+ @path = prepare_package(@original_path).to_s
35
44
  @load_type = load_type || determine_load_type(path)
36
45
  create_package_structure
37
46
  load_package
38
47
  @name = metadata.name
48
+ verify_integrity!
39
49
  end
40
50
 
41
51
  def prepare_package(path)
@@ -44,45 +54,23 @@ module Capsium
44
54
  if File.file?(path)
45
55
  return decompress_cap_file(path) if File.extname(path) == ".cap"
46
56
 
47
- raise "Error: The package must have a .cap extension"
57
+ raise Error, "The package must have a .cap extension"
48
58
  end
49
59
 
50
- raise "Invalid package path: #{path}"
60
+ raise Error, "Invalid package path: #{path}"
51
61
  end
52
62
 
53
63
  def solidify
54
64
  @manifest.save_to_file
55
65
  @metadata.save_to_file
56
66
  @routes.save_to_file
57
- @storage.save_to_file
67
+ @storage.save_to_file unless @storage.empty?
58
68
  end
59
69
 
60
70
  def decompress_cap_file(file_path)
61
- temp_dir = Dir.mktmpdir
62
- metadata_path = File.join(temp_dir, METADATA_FILE)
63
-
64
- # Extract metadata.json first
65
- Zip::File.open(file_path) do |zip_file|
66
- if entry = zip_file.find_entry(METADATA_FILE)
67
- entry.extract(metadata_path)
68
- end
69
- end
70
-
71
- metadata = Metadata.new(metadata_path)
72
- package_name = metadata.name
73
- package_version = metadata.version
74
-
75
- package_path = File.join(temp_dir, "#{package_name}-#{package_version}")
71
+ package_path = File.join(Dir.mktmpdir, package_stem(file_path))
76
72
  FileUtils.mkdir_p(package_path)
77
-
78
- Zip::File.open(file_path) do |zip_file|
79
- zip_file.each do |entry|
80
- entry_path = File.join(package_path, entry.name)
81
- FileUtils.mkdir_p(File.dirname(entry_path))
82
- entry.extract(entry_path)
83
- end
84
- end
85
-
73
+ Packager.new.unpack(file_path, package_path)
86
74
  package_path
87
75
  end
88
76
 
@@ -94,16 +82,23 @@ module Capsium
94
82
  @manifest = Manifest.new(manifest_path)
95
83
  @storage = Storage.new(storage_path)
96
84
  @routes = Routes.new(routes_path, @manifest, @storage)
85
+ @security = Security.new(security_path)
97
86
  end
98
87
 
99
88
  def cleanup
100
- return unless @path != @original_path && File.directory?(@path)
89
+ return unless @path != @original_path.to_s && File.directory?(@path)
101
90
 
102
91
  FileUtils.remove_entry(@path)
103
92
  end
104
93
 
105
- def package_files
106
- @packager.package_files
94
+ def datasets
95
+ storage.datasets
96
+ end
97
+
98
+ # The .cap file this package was loaded from, or nil when loaded
99
+ # from a directory.
100
+ def cap_file_path
101
+ @original_path.to_s if load_type == :cap_file
107
102
  end
108
103
 
109
104
  def content_files
@@ -119,36 +114,43 @@ module Capsium
119
114
  :unsaved
120
115
  end
121
116
 
117
+ # Verifies the package against security.json (ARCHITECTURE.md section
118
+ # 6). Returns a list of typed errors; empty when no security.json is
119
+ # present or all checksums match.
120
+ def verify_integrity
121
+ return [] unless @security.present?
122
+
123
+ @security.verify(@path)
124
+ end
125
+
126
+ def verify_integrity!
127
+ @security.verify!(@path) if @security.present?
128
+ end
129
+
122
130
  private
123
131
 
132
+ def package_stem(file_path)
133
+ File.basename(file_path, ".cap")
134
+ end
135
+
124
136
  def create_package_structure
125
137
  FileUtils.mkdir_p(@path)
126
138
  FileUtils.mkdir_p(content_path)
127
139
  FileUtils.mkdir_p(data_path)
128
140
  end
129
141
 
130
- def content_path
131
- File.join(@path, CONTENT_DIR)
132
- end
142
+ def content_path = File.join(@path, CONTENT_DIR)
133
143
 
134
- def data_path
135
- File.join(@path, DATA_DIR)
136
- end
144
+ def data_path = File.join(@path, DATA_DIR)
137
145
 
138
- def routes_path
139
- File.join(@path, ROUTES_FILE)
140
- end
146
+ def routes_path = File.join(@path, ROUTES_FILE)
141
147
 
142
- def storage_path
143
- File.join(@path, STORAGE_FILE)
144
- end
148
+ def storage_path = File.join(@path, STORAGE_FILE)
145
149
 
146
- def metadata_path
147
- File.join(@path, METADATA_FILE)
148
- end
150
+ def metadata_path = File.join(@path, METADATA_FILE)
149
151
 
150
- def manifest_path
151
- File.join(@path, MANIFEST_FILE)
152
- end
152
+ def manifest_path = File.join(@path, MANIFEST_FILE)
153
+
154
+ def security_path = File.join(@path, SECURITY_FILE)
153
155
  end
154
156
  end
@@ -1,15 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/packager.rb
4
- require "json"
5
3
  require "fileutils"
6
- require "rubygems"
4
+ require "pathname"
5
+ require "tmpdir"
7
6
  require "zip"
8
7
 
9
8
  module Capsium
10
9
  class Packager
11
10
  class FileAlreadyExistsError < StandardError; end
12
11
 
12
+ # Raised when a .cap archive contains an entry whose name would be
13
+ # written outside the destination directory (absolute paths, drive
14
+ # letters, ".." segments) — zip-slip protection, on par with the Lua
15
+ # reactor's extractor.
16
+ class UnsafeEntryError < Capsium::Error; end
17
+
18
+ DRIVE_LETTER_PATTERN = %r{\A[A-Za-z]:[/\\]}
19
+
13
20
  def pack(package, options = {})
14
21
  directory = package.path
15
22
  output_file_name = "#{package.metadata.name}-#{package.metadata.version}.cap"
@@ -26,8 +33,10 @@ module Capsium
26
33
 
27
34
  Dir.mktmpdir do |dir|
28
35
  FileUtils.cp_r("#{directory}/.", dir)
36
+ FileUtils.rm_f(File.join(dir, Package::SECURITY_FILE))
29
37
  new_package = Package.new(dir)
30
38
  new_package.solidify
39
+ generate_security(new_package)
31
40
  new_cap_file_path = File.join(dir, output_file_name)
32
41
  compress_package(new_package, new_cap_file_path)
33
42
  puts "Package built at: #{new_cap_file_path}"
@@ -38,9 +47,10 @@ module Capsium
38
47
  end
39
48
 
40
49
  def unpack(cap_file_path, destination)
50
+ destination = File.expand_path(destination)
41
51
  Zip::File.open(cap_file_path) do |zip_file|
42
52
  zip_file.each do |entry|
43
- entry_path = File.join(destination, entry.name)
53
+ entry_path = safe_entry_path(destination, entry.name)
44
54
  FileUtils.mkdir_p(File.dirname(entry_path))
45
55
  entry.extract(entry_path)
46
56
  end
@@ -48,8 +58,11 @@ module Capsium
48
58
  end
49
59
 
50
60
  def compress_package(package, cap_file)
51
- Zip::File.open(cap_file, Zip::File::CREATE) do |zipfile|
52
- Dir[File.join(package.path, "**", "**")].each do |file|
61
+ entries = Dir[File.join(package.path, "**", "**")].reject do |file|
62
+ File.expand_path(file) == File.expand_path(cap_file)
63
+ end
64
+ Zip::File.open(cap_file, create: true) do |zipfile|
65
+ entries.each do |file|
53
66
  zipfile.add(file.sub("#{package.path}/", ""), file)
54
67
  end
55
68
  end
@@ -58,5 +71,28 @@ module Capsium
58
71
  def relative_path_current(absolute_path)
59
72
  Pathname.new(absolute_path).relative_path_from(Dir.pwd).to_s
60
73
  end
74
+
75
+ private
76
+
77
+ # Resolves an entry name against the destination and returns the
78
+ # absolute target path, raising UnsafeEntryError when the entry would
79
+ # escape the destination (zip-slip).
80
+ def safe_entry_path(destination, entry_name)
81
+ entry_path = File.expand_path(entry_name, destination)
82
+ return entry_path if contained?(entry_path, destination) &&
83
+ !entry_name.match?(DRIVE_LETTER_PATTERN)
84
+
85
+ raise UnsafeEntryError,
86
+ "Refusing to extract unsafe zip entry: #{entry_name}"
87
+ end
88
+
89
+ def contained?(entry_path, destination)
90
+ entry_path.start_with?("#{destination}#{File::SEPARATOR}")
91
+ end
92
+
93
+ def generate_security(package)
94
+ security = Package::Security.generate(package.path)
95
+ security.save_to_file
96
+ end
61
97
  end
62
98
  end