app-info 2.8.5 → 3.0.0.beta2

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +12 -7
  3. data/.github/workflows/ci.yml +3 -1
  4. data/.rubocop.yml +33 -11
  5. data/CHANGELOG.md +34 -1
  6. data/Gemfile +7 -1
  7. data/README.md +68 -13
  8. data/Rakefile +11 -0
  9. data/app_info.gemspec +12 -3
  10. data/lib/app_info/aab.rb +48 -108
  11. data/lib/app_info/android/signature.rb +114 -0
  12. data/lib/app_info/android/signatures/base.rb +53 -0
  13. data/lib/app_info/android/signatures/info.rb +158 -0
  14. data/lib/app_info/android/signatures/v1.rb +63 -0
  15. data/lib/app_info/android/signatures/v2.rb +121 -0
  16. data/lib/app_info/android/signatures/v3.rb +131 -0
  17. data/lib/app_info/android/signatures/v4.rb +18 -0
  18. data/lib/app_info/android.rb +162 -0
  19. data/lib/app_info/apk.rb +54 -111
  20. data/lib/app_info/apple.rb +192 -0
  21. data/lib/app_info/certificate.rb +175 -0
  22. data/lib/app_info/const.rb +75 -0
  23. data/lib/app_info/core_ext/object/try.rb +3 -1
  24. data/lib/app_info/core_ext/string/inflector.rb +2 -0
  25. data/lib/app_info/dsym/debug_info.rb +72 -0
  26. data/lib/app_info/dsym/macho.rb +55 -0
  27. data/lib/app_info/dsym.rb +31 -135
  28. data/lib/app_info/error.rb +2 -2
  29. data/lib/app_info/file.rb +49 -0
  30. data/lib/app_info/helper/archive.rb +37 -0
  31. data/lib/app_info/helper/file_size.rb +25 -0
  32. data/lib/app_info/helper/generate_class.rb +29 -0
  33. data/lib/app_info/helper/protobuf.rb +12 -0
  34. data/lib/app_info/helper/signatures.rb +229 -0
  35. data/lib/app_info/helper.rb +5 -126
  36. data/lib/app_info/info_plist.rb +66 -29
  37. data/lib/app_info/ipa/framework.rb +4 -4
  38. data/lib/app_info/ipa.rb +61 -135
  39. data/lib/app_info/macos.rb +54 -102
  40. data/lib/app_info/mobile_provision.rb +67 -49
  41. data/lib/app_info/pe.rb +260 -0
  42. data/lib/app_info/png_uncrush.rb +24 -4
  43. data/lib/app_info/proguard.rb +29 -16
  44. data/lib/app_info/protobuf/manifest.rb +6 -3
  45. data/lib/app_info/protobuf/models/Configuration_pb.rb +1 -0
  46. data/lib/app_info/protobuf/models/README.md +7 -0
  47. data/lib/app_info/protobuf/models/Resources_pb.rb +2 -0
  48. data/lib/app_info/protobuf/resources.rb +5 -5
  49. data/lib/app_info/version.rb +1 -1
  50. data/lib/app_info.rb +90 -46
  51. metadata +48 -35
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppInfo
4
+ # Certificate wrapper for
5
+ # {https://docs.ruby-lang.org/en/3.0/OpenSSL/X509/Certificate.html OpenSSL::X509::Certifiate}.
6
+ class Certificate
7
+ # Parse Raw data into X509 cerificate wrapper
8
+ # @param [String] certificate raw data
9
+ def self.parse(data)
10
+ cert = OpenSSL::X509::Certificate.new(data)
11
+ new(cert)
12
+ end
13
+
14
+ # @param [OpenSSL::X509::Certificate] certificate
15
+ def initialize(cert)
16
+ @cert = cert
17
+ end
18
+
19
+ # return version of certificate
20
+ # @param [String] prefix
21
+ # @param [Integer] base
22
+ # @return [String] version
23
+ def version(prefix: 'v', base: 1)
24
+ "#{prefix}#{raw.version + base}"
25
+ end
26
+
27
+ # return serial of certificate
28
+ # @param [Integer] base
29
+ # @param [Symbol] transform avaiables in :lower, :upper
30
+ # @param [String] prefix
31
+ # @return [String] serial
32
+ def serial(base = 10, transform: :lower, prefix: nil)
33
+ serial = raw.serial.to_s(base)
34
+ serial = transform == :lower ? serial.downcase : serial.upcase
35
+ return serial unless prefix
36
+
37
+ "#{prefix}#{serial}"
38
+ end
39
+
40
+ # return issuer from DN, similar to {#subject}.
41
+ #
42
+ # Example:
43
+ #
44
+ # @param [Symbol] format avaiables in `:to_a`, `:to_s` and `:raw`
45
+ # @return [Array, String, OpenSSL::X509::Name] the object converted into the expected format.
46
+ def issuer(format: :raw)
47
+ convert_cert_name(raw.issuer, format: format)
48
+ end
49
+
50
+ # return subject from DN, similar to {#issuer}.
51
+ # @param [Symbol] format avaiables in `:to_a`, `:to_s` and `:raw`
52
+ # @return [Array, String, OpenSSL::X509::Name] the object converted into the expected format.
53
+ def subject(format: :raw)
54
+ convert_cert_name(raw.subject, format: format)
55
+ end
56
+
57
+ # @return [Time]
58
+ def created_at
59
+ raw.not_before
60
+ end
61
+
62
+ # @return [Time]
63
+ def expired_at
64
+ raw.not_after
65
+ end
66
+
67
+ # @return [Boolean]
68
+ def expired?
69
+ expired_at < Time.now.utc
70
+ end
71
+
72
+ # @return [String] format always be :x509.
73
+ def format
74
+ :x509
75
+ end
76
+
77
+ # return algorithm digest
78
+ #
79
+ # OpenSSL supported digests:
80
+ #
81
+ # -blake2b512 -blake2s256 -md4
82
+ # -md5 -md5-sha1 -mdc2
83
+ # -ripemd -ripemd160 -rmd160
84
+ # -sha1 -sha224 -sha256
85
+ # -sha3-224 -sha3-256 -sha3-384
86
+ # -sha3-512 -sha384 -sha512
87
+ # -sha512-224 -sha512-256 -shake128
88
+ # -shake256 -sm3 -ssl3-md5
89
+ # -ssl3-sha1 -whirlpool
90
+ def digest
91
+ signature_algorithm = raw.signature_algorithm
92
+
93
+ case signature_algorithm
94
+ when /md5/
95
+ :md5
96
+ when /sha1/
97
+ :sha1
98
+ when /sha224/
99
+ :sha224
100
+ when /sha256/
101
+ :sha256
102
+ when /sha512/
103
+ :sha512
104
+ else
105
+ # Android signature no need the others
106
+ signature_algorithm.to_sym
107
+ end
108
+ end
109
+
110
+ # return algorithm name of public key
111
+ def algorithm
112
+ case public_key
113
+ when OpenSSL::PKey::RSA then :rsa
114
+ when OpenSSL::PKey::DSA then :dsa
115
+ when OpenSSL::PKey::DH then :dh
116
+ when OpenSSL::PKey::EC then :ec
117
+ end
118
+ end
119
+
120
+ # return size of public key
121
+ # @return [Integer]
122
+ def size
123
+ case public_key
124
+ when OpenSSL::PKey::RSA
125
+ public_key.n.num_bits
126
+ when OpenSSL::PKey::DSA, OpenSSL::PKey::DH
127
+ public_key.p.num_bits
128
+ when OpenSSL::PKey::EC
129
+ raise NotImplementedError, "key size for #{public_key.inspect} not implemented"
130
+ end
131
+ end
132
+
133
+ # return fingerprint of certificate
134
+ # @return [String]
135
+ def fingerprint(name = :sha256, transform: :lower, delimiter: nil)
136
+ digest = OpenSSL::Digest.new(name.to_s.upcase)
137
+ digest.update(raw.to_der)
138
+ fingerprint = digest.to_s
139
+ fingerprint = fingerprint.upcase if transform.to_sym == :upper
140
+ return fingerprint unless delimiter
141
+
142
+ fingerprint.scan(/../).join(delimiter)
143
+ end
144
+
145
+ # Orginal OpenSSL X509 certificate
146
+ # @return [OpenSSL::X509::Certificate]
147
+ def raw
148
+ @cert
149
+ end
150
+
151
+ private
152
+
153
+ def convert_cert_name(name, format:)
154
+ data = name.to_a
155
+ case format
156
+ when :to_a
157
+ data.map { |k, v, _| [k, v] }
158
+ when :to_s
159
+ data.map { |k, v, _| "#{k}=#{v}" }.join(' ')
160
+ else
161
+ name
162
+ end
163
+ end
164
+
165
+ def method_missing(method, *args, &block)
166
+ @cert.send(method.to_sym, *args, &block) || super
167
+ end
168
+
169
+ def respond_to_missing?(method_name, *args)
170
+ @cert.key?(method_name.to_sym) ||
171
+ @cert.respond_to?(method_name) ||
172
+ super
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppInfo
4
+ # Full Format
5
+ module Format
6
+ # Apple
7
+
8
+ INFOPLIST = :infoplist
9
+ MOBILEPROVISION = :mobileprovision
10
+ DSYM = :dsym
11
+
12
+ # macOS
13
+
14
+ MACOS = :macos
15
+
16
+ # iOS
17
+
18
+ IPA = :ipa
19
+
20
+ # Android
21
+
22
+ APK = :apk
23
+ AAB = :aab
24
+ PROGUARD = :proguard
25
+
26
+ # Windows
27
+
28
+ PE = :pe
29
+
30
+ UNKNOWN = :unknown
31
+ end
32
+
33
+ # Platform
34
+ module Platform
35
+ APPLE = :apple
36
+ GOOGLE = :google
37
+ WINDOWS = :windows
38
+ end
39
+
40
+ module OperaSystem
41
+ MACOS = :macos
42
+ IOS = :ios
43
+ ANDROID = :android
44
+ WINDOWS = :windows
45
+ end
46
+
47
+ # Apple Device Type
48
+ module Device
49
+ # macOS
50
+ MACOS = :macos
51
+
52
+ # Apple iPhone
53
+ IPHONE = :iphone
54
+ # Apple iPad
55
+ IPAD = :ipad
56
+ # Apple Watch
57
+ IWATCH = :iwatch # not implemented yet
58
+ # Apple Universal (iPhone and iPad)
59
+ UNIVERSAL = :universal
60
+
61
+ # Android Phone
62
+ PHONE = :phone
63
+ # Android Tablet (not implemented yet)
64
+ TABLET = :tablet
65
+ # Android Watch
66
+ WATCH = :watch
67
+ # Android TV
68
+ TELEVISION = :television
69
+ # Android Car Automotive
70
+ AUTOMOTIVE = :automotive
71
+
72
+ # Windows
73
+ WINDOWS = :windows
74
+ end
75
+ end
@@ -4,7 +4,8 @@
4
4
  # Copy from https://github.com/rails/rails/blob/master/activesupport/lib/active_support/core_ext/object/try.rb
5
5
 
6
6
  module AppInfo
7
- module Tryable # :nodoc:
7
+ # @!visibility private
8
+ module Tryable
8
9
  ##
9
10
  # :method: try
10
11
  #
@@ -107,6 +108,7 @@ module AppInfo
107
108
  end
108
109
  end
109
110
 
111
+ # @!visibility private
110
112
  class Object
111
113
  include AppInfo::Tryable
112
114
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppInfo
4
+ # @!visibility private
4
5
  module Inflector
5
6
  def ai_snakecase
6
7
  gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
@@ -30,6 +31,7 @@ module AppInfo
30
31
  end
31
32
  end
32
33
 
34
+ # @!visibility private
33
35
  class String
34
36
  include AppInfo::Inflector
35
37
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'app_info/dsym/macho'
4
+
5
+ module AppInfo
6
+ class DSYM < File
7
+ # DSYM Debug Information Format Struct
8
+ class DebugInfo
9
+ attr_reader :path
10
+
11
+ def initialize(path)
12
+ @path = path
13
+ end
14
+
15
+ def object
16
+ @object ||= ::File.basename(bin_path)
17
+ end
18
+
19
+ def macho_type
20
+ @macho_type ||= ::MachO.open(bin_path)
21
+ end
22
+
23
+ def machos
24
+ @machos ||= case macho_type
25
+ when ::MachO::MachOFile
26
+ [MachO.new(macho_type, ::File.size(bin_path))]
27
+ else
28
+ size = macho_type.fat_archs.each_with_object([]) do |arch, obj|
29
+ obj << arch.size
30
+ end
31
+
32
+ machos = []
33
+ macho_type.machos.each_with_index do |file, i|
34
+ machos << MachO.new(file, size[i])
35
+ end
36
+ machos
37
+ end
38
+ end
39
+
40
+ def release_version
41
+ info.try(:[], 'CFBundleShortVersionString')
42
+ end
43
+
44
+ def build_version
45
+ info.try(:[], 'CFBundleVersion')
46
+ end
47
+
48
+ def identifier
49
+ info.try(:[], 'CFBundleIdentifier').sub('com.apple.xcode.dsym.', '')
50
+ end
51
+ alias bundle_id identifier
52
+
53
+ def info
54
+ return nil unless ::File.exist?(info_path)
55
+
56
+ @info ||= CFPropertyList.native_types(CFPropertyList::List.new(file: info_path).value)
57
+ end
58
+
59
+ def info_path
60
+ @info_path ||= ::File.join(path, 'Contents', 'Info.plist')
61
+ end
62
+
63
+ def bin_path
64
+ @bin_path ||= lambda {
65
+ dwarf_path = ::File.join(path, 'Contents', 'Resources', 'DWARF')
66
+ name = Dir.children(dwarf_path)[0]
67
+ ::File.join(dwarf_path, name)
68
+ }.call
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'macho'
4
+
5
+ module AppInfo
6
+ class DSYM < File
7
+ # Mach-O Struct
8
+ class MachO
9
+ include Helper::HumanFileSize
10
+
11
+ def initialize(file, size = 0)
12
+ @file = file
13
+ @size = size
14
+ end
15
+
16
+ def cpu_name
17
+ @file.cpusubtype
18
+ end
19
+
20
+ def cpu_type
21
+ @file.cputype
22
+ end
23
+
24
+ def type
25
+ @file.filetype
26
+ end
27
+
28
+ def size(human_size: false)
29
+ return number_to_human_size(@size) if human_size
30
+
31
+ @size
32
+ end
33
+
34
+ def uuid
35
+ @file[:LC_UUID][0].uuid_string
36
+ end
37
+ alias debug_id uuid
38
+
39
+ def header
40
+ @header ||= @file.header
41
+ end
42
+
43
+ def to_h
44
+ {
45
+ uuid: uuid,
46
+ type: type,
47
+ cpu_name: cpu_name,
48
+ cpu_type: cpu_type,
49
+ size: size,
50
+ human_size: size(human_size: true)
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
data/lib/app_info/dsym.rb CHANGED
@@ -1,78 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'macho'
3
+ require 'app_info/dsym/debug_info'
4
4
 
5
5
  module AppInfo
6
6
  # DSYM parser
7
- class DSYM
7
+ class DSYM < File
8
8
  include Helper::Archive
9
9
 
10
- attr_reader :file
11
-
12
- def initialize(file)
13
- @file = file
14
- end
15
-
16
- def file_type
17
- Platform::DSYM
18
- end
19
-
20
- def object
21
- @object ||= File.basename(app_path)
22
- end
23
-
24
- def macho_type
25
- @macho_type ||= ::MachO.open(app_path)
26
- end
27
-
28
- def machos
29
- @machos ||= case macho_type
30
- when ::MachO::MachOFile
31
- [MachO.new(macho_type, File.size(app_path))]
32
- else
33
- size = macho_type.fat_archs.each_with_object([]) do |arch, obj|
34
- obj << arch.size
35
- end
36
-
37
- machos = []
38
- macho_type.machos.each_with_index do |file, i|
39
- machos << MachO.new(file, size[i])
40
- end
41
- machos
42
- end
43
- end
44
-
45
- def release_version
46
- info.try(:[], 'CFBundleShortVersionString')
10
+ # @return [Symbol] {Platform}
11
+ def platform
12
+ Platform::APPLE
47
13
  end
48
14
 
49
- def build_version
50
- info.try(:[], 'CFBundleVersion')
15
+ def each_file(&block)
16
+ files.each { |file| block.call(file) }
51
17
  end
52
18
 
53
- def identifier
54
- info.try(:[], 'CFBundleIdentifier').sub('com.apple.xcode.dsym.', '')
55
- end
56
- alias bundle_id identifier
57
-
58
- def info
59
- return nil unless File.exist?(info_path)
60
-
61
- @info ||= CFPropertyList.native_types(CFPropertyList::List.new(file: info_path).value)
62
- end
63
-
64
- def info_path
65
- @info_path ||= File.join(contents, 'Contents', 'Info.plist')
66
- end
67
-
68
- def app_path
69
- unless @app_path
70
- path = File.join(contents, 'Contents', 'Resources', 'DWARF')
71
- name = Dir.entries(path).reject { |f| ['.', '..'].include?(f) }.first
72
- @app_path = File.join(path, name)
19
+ # @return [Array<DebugInfo>] dsym_files files by alphabetical order
20
+ def files
21
+ @files ||= Dir.children(contents).sort.each_with_object([]) do |file, obj|
22
+ obj << DebugInfo.new(::File.join(contents, file))
73
23
  end
74
-
75
- @app_path
76
24
  end
77
25
 
78
26
  def clear!
@@ -81,85 +29,33 @@ module AppInfo
81
29
  FileUtils.rm_rf(@contents)
82
30
 
83
31
  @contents = nil
84
- @app_path = nil
85
- @info = nil
86
- @object = nil
87
- @macho_type = nil
32
+ @files = nil
88
33
  end
89
34
 
35
+ # @return [String] contents path of dsym
90
36
  def contents
91
- unless @contents
92
- if File.directory?(@file)
93
- @contents = @file
94
- else
95
- dsym_dir = nil
96
- @contents = unarchive(@file, path: 'dsym') do |path, zip_file|
97
- zip_file.each do |f|
98
- unless dsym_dir
99
- dsym_dir = f.name
100
- # fix filename is xxx.app.dSYM/Contents
101
- dsym_dir = dsym_dir.split('/')[0] if dsym_dir.include?('/')
102
- end
37
+ @contents ||= lambda {
38
+ return @file if ::File.directory?(@file)
103
39
 
104
- f_path = File.join(path, f.name)
105
- FileUtils.mkdir_p(File.dirname(f_path))
106
- f.extract(f_path) unless File.exist?(f_path)
107
- end
108
- end
40
+ dsym_filenames = []
41
+ unarchive(@file, prefix: 'dsym') do |base_path, zip_file|
42
+ zip_file.each do |entry|
43
+ file_path = entry.name
44
+ next unless file_path.downcase.include?('.dsym/contents/')
45
+ next if ::File.basename(file_path).start_with?('.')
109
46
 
110
- @contents = File.join(@contents, dsym_dir)
111
- end
112
- end
113
-
114
- @contents
115
- end
116
-
117
- # DSYM Mach-O
118
- class MachO
119
- include Helper::HumanFileSize
120
-
121
- def initialize(file, size = 0)
122
- @file = file
123
- @size = size
124
- end
125
-
126
- def cpu_name
127
- @file.cpusubtype
128
- end
129
-
130
- def cpu_type
131
- @file.cputype
132
- end
133
-
134
- def type
135
- @file.filetype
136
- end
137
-
138
- def size(human_size: false)
139
- return number_to_human_size(@size) if human_size
140
-
141
- @size
142
- end
143
-
144
- def uuid
145
- @file[:LC_UUID][0].uuid_string
146
- end
147
- alias debug_id uuid
47
+ dsym_filename = file_path.split('/').select { |f| f.downcase.end_with?('.dsym') }.last
48
+ dsym_filenames << dsym_filename unless dsym_filenames.include?(dsym_filename)
148
49
 
149
- def header
150
- @header ||= @file.header
151
- end
50
+ unless file_path.start_with?(dsym_filename)
51
+ file_path = file_path.split('/')[1..-1].join('/')
52
+ end
152
53
 
153
- def to_h
154
- {
155
- uuid: uuid,
156
- type: type,
157
- cpu_name: cpu_name,
158
- cpu_type: cpu_type,
159
- size: size,
160
- human_size: size(human_size: true)
161
- }
162
- end
54
+ dest_path = ::File.join(base_path, file_path)
55
+ entry.extract(dest_path) unless ::File.exist?(dest_path)
56
+ end
57
+ end
58
+ }.call
163
59
  end
164
60
  end
165
61
  end
@@ -9,7 +9,7 @@ module AppInfo
9
9
 
10
10
  class NotFoundError < Error; end
11
11
 
12
- class UnkownFileTypeError < Error; end
13
-
14
12
  class ProtobufParseError < Error; end
13
+
14
+ class UnknownFormatError < Error; end
15
15
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AppInfo base file
4
+ module AppInfo
5
+ class File
6
+ attr_reader :file, :logger
7
+
8
+ def initialize(file, logger: AppInfo.logger)
9
+ @file = file
10
+ @logger = logger
11
+ end
12
+
13
+ # @return [Symbol] {Format}
14
+ def format
15
+ @format ||= lambda {
16
+ if instance_of?(AppInfo::File) || instance_of?(AppInfo::Apple) ||
17
+ instance_of?(AppInfo::Android)
18
+ not_implemented_error!(__method__)
19
+ end
20
+
21
+ self.class.name.split('::')[-1].downcase.to_sym
22
+ }.call
23
+ end
24
+
25
+ # @abstract Subclass and override {#opera_system} to implement.
26
+ def opera_system
27
+ not_implemented_error!(__method__)
28
+ end
29
+
30
+ # @abstract Subclass and override {#platform} to implement.
31
+ def platform
32
+ not_implemented_error!(__method__)
33
+ end
34
+
35
+ # @abstract Subclass and override {#device} to implement.
36
+ def device
37
+ not_implemented_error!(__method__)
38
+ end
39
+
40
+ # @abstract Subclass and override {#size} to implement
41
+ def size(human_size: false)
42
+ not_implemented_error!(__method__)
43
+ end
44
+
45
+ def not_implemented_error!(method)
46
+ raise NotImplementedError, ".#{method} method implantation required in #{self.class}"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zip'
4
+ require 'tmpdir'
5
+ require 'fileutils'
6
+ require 'securerandom'
7
+
8
+ module AppInfo::Helper
9
+ module Archive
10
+ # Unarchive zip file
11
+ #
12
+ # source: https://github.com/soffes/lagunitas/blob/master/lib/lagunitas/ipa.rb
13
+ def unarchive(file, prefix:, dest_path: '/tmp')
14
+ base_path = Dir.mktmpdir("appinfo-#{prefix}", dest_path)
15
+ Zip::File.open(file) do |zip_file|
16
+ if block_given?
17
+ yield base_path, zip_file
18
+ else
19
+ zip_file.each do |f|
20
+ f_path = ::File.join(base_path, f.name)
21
+ FileUtils.mkdir_p(::File.dirname(f_path))
22
+ zip_file.extract(f, f_path) unless ::File.exist?(f_path)
23
+ end
24
+ end
25
+ end
26
+
27
+ base_path
28
+ end
29
+
30
+ def tempdir(file, prefix:, system: false)
31
+ base_path = system ? '/tmp' : ::File.dirname(file)
32
+ full_prefix = "appinfo-#{prefix}-#{::File.basename(file, '.*')}"
33
+ dest_path = Dir.mktmpdir(full_prefix, base_path)
34
+ ::File.join(dest_path, ::File.basename(file))
35
+ end
36
+ end
37
+ end