rubyzip 1.2.0 → 1.3.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 +5 -5
- data/README.md +95 -43
- data/lib/zip.rb +11 -1
- data/lib/zip/central_directory.rb +3 -3
- data/lib/zip/compressor.rb +1 -2
- data/lib/zip/constants.rb +3 -3
- data/lib/zip/crypto/null_encryption.rb +2 -4
- data/lib/zip/decompressor.rb +1 -1
- data/lib/zip/dos_time.rb +1 -1
- data/lib/zip/entry.rb +70 -54
- data/lib/zip/entry_set.rb +4 -4
- data/lib/zip/errors.rb +1 -0
- data/lib/zip/extra_field.rb +2 -2
- data/lib/zip/extra_field/generic.rb +1 -1
- data/lib/zip/extra_field/zip64_placeholder.rb +1 -2
- data/lib/zip/file.rb +62 -51
- data/lib/zip/filesystem.rb +17 -13
- data/lib/zip/inflater.rb +2 -2
- data/lib/zip/input_stream.rb +10 -7
- data/lib/zip/ioextras/abstract_input_stream.rb +1 -1
- data/lib/zip/ioextras/abstract_output_stream.rb +3 -3
- data/lib/zip/output_stream.rb +5 -5
- data/lib/zip/pass_thru_decompressor.rb +1 -1
- data/lib/zip/streamable_stream.rb +1 -1
- data/lib/zip/version.rb +1 -1
- data/samples/example_recursive.rb +15 -18
- data/samples/gtk_ruby_zip.rb +1 -1
- data/samples/qtzip.rb +1 -1
- data/samples/zipfind.rb +2 -2
- data/test/central_directory_entry_test.rb +2 -2
- data/test/crypto/null_encryption_test.rb +6 -2
- data/test/data/gpbit3stored.zip +0 -0
- data/test/data/path_traversal/Makefile +10 -0
- data/test/data/path_traversal/jwilk/README.md +5 -0
- data/test/data/path_traversal/jwilk/absolute1.zip +0 -0
- data/test/data/path_traversal/jwilk/absolute2.zip +0 -0
- data/test/data/path_traversal/jwilk/dirsymlink.zip +0 -0
- data/test/data/path_traversal/jwilk/dirsymlink2a.zip +0 -0
- data/test/data/path_traversal/jwilk/dirsymlink2b.zip +0 -0
- data/test/data/path_traversal/jwilk/relative0.zip +0 -0
- data/test/data/path_traversal/jwilk/relative2.zip +0 -0
- data/test/data/path_traversal/jwilk/symlink.zip +0 -0
- data/test/data/path_traversal/relative1.zip +0 -0
- data/test/data/path_traversal/tilde.zip +0 -0
- data/test/data/path_traversal/tuzovakaoff/README.md +3 -0
- data/test/data/path_traversal/tuzovakaoff/absolutepath.zip +0 -0
- data/test/data/path_traversal/tuzovakaoff/symlink.zip +0 -0
- data/test/data/rubycode.zip +0 -0
- data/test/entry_set_test.rb +13 -2
- data/test/entry_test.rb +3 -12
- data/test/errors_test.rb +1 -0
- data/test/file_extract_test.rb +62 -0
- data/test/file_permissions_test.rb +39 -43
- data/test/file_test.rb +115 -12
- data/test/filesystem/dir_iterator_test.rb +1 -1
- data/test/filesystem/directory_test.rb +29 -11
- data/test/filesystem/file_mutating_test.rb +3 -4
- data/test/filesystem/file_nonmutating_test.rb +34 -34
- data/test/filesystem/file_stat_test.rb +5 -5
- data/test/gentestfiles.rb +17 -13
- data/test/input_stream_test.rb +10 -10
- data/test/ioextras/abstract_input_stream_test.rb +1 -1
- data/test/ioextras/abstract_output_stream_test.rb +2 -2
- data/test/ioextras/fake_io_test.rb +1 -1
- data/test/local_entry_test.rb +1 -1
- data/test/path_traversal_test.rb +141 -0
- data/test/test_helper.rb +16 -3
- data/test/unicode_file_names_and_comments_test.rb +12 -0
- data/test/zip64_full_test.rb +2 -2
- metadata +103 -51
data/lib/zip/output_stream.rb
CHANGED
@@ -87,11 +87,11 @@ module Zip
|
|
87
87
|
# +entry+ can be a ZipEntry object or a string.
|
88
88
|
def put_next_entry(entry_name, comment = nil, extra = nil, compression_method = Entry::DEFLATED, level = Zip.default_compression)
|
89
89
|
raise Error, 'zip stream is closed' if @closed
|
90
|
-
if entry_name.kind_of?(Entry)
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
90
|
+
new_entry = if entry_name.kind_of?(Entry)
|
91
|
+
entry_name
|
92
|
+
else
|
93
|
+
Entry.new(@file_name, entry_name.to_s)
|
94
|
+
end
|
95
95
|
new_entry.comment = comment unless comment.nil?
|
96
96
|
unless extra.nil?
|
97
97
|
new_entry.extra = extra.is_a?(ExtraField) ? extra : ExtraField.new(extra.to_s)
|
data/lib/zip/version.rb
CHANGED
@@ -6,7 +6,7 @@ require 'zip'
|
|
6
6
|
# included in the archive, rather just its contents.
|
7
7
|
#
|
8
8
|
# Usage:
|
9
|
-
#
|
9
|
+
# directory_to_zip = "/tmp/input"
|
10
10
|
# output_file = "/tmp/out.zip"
|
11
11
|
# zf = ZipFileGenerator.new(directory_to_zip, output_file)
|
12
12
|
# zf.write()
|
@@ -19,39 +19,36 @@ class ZipFileGenerator
|
|
19
19
|
|
20
20
|
# Zip the input directory.
|
21
21
|
def write
|
22
|
-
entries = Dir.entries(@input_dir) - %w
|
22
|
+
entries = Dir.entries(@input_dir) - %w[. ..]
|
23
23
|
|
24
|
-
::Zip::File.open(@output_file, ::Zip::File::CREATE) do |
|
25
|
-
write_entries entries, '',
|
24
|
+
::Zip::File.open(@output_file, ::Zip::File::CREATE) do |zipfile|
|
25
|
+
write_entries entries, '', zipfile
|
26
26
|
end
|
27
27
|
end
|
28
28
|
|
29
29
|
private
|
30
30
|
|
31
31
|
# A helper method to make the recursion work.
|
32
|
-
def write_entries(entries, path,
|
32
|
+
def write_entries(entries, path, zipfile)
|
33
33
|
entries.each do |e|
|
34
|
-
|
35
|
-
disk_file_path = File.join(@input_dir,
|
36
|
-
puts "Deflating #{disk_file_path}"
|
34
|
+
zipfile_path = path == '' ? e : File.join(path, e)
|
35
|
+
disk_file_path = File.join(@input_dir, zipfile_path)
|
37
36
|
|
38
37
|
if File.directory? disk_file_path
|
39
|
-
recursively_deflate_directory(disk_file_path,
|
38
|
+
recursively_deflate_directory(disk_file_path, zipfile, zipfile_path)
|
40
39
|
else
|
41
|
-
put_into_archive(disk_file_path,
|
40
|
+
put_into_archive(disk_file_path, zipfile, zipfile_path)
|
42
41
|
end
|
43
42
|
end
|
44
43
|
end
|
45
44
|
|
46
|
-
def recursively_deflate_directory(disk_file_path,
|
47
|
-
|
48
|
-
subdir = Dir.entries(disk_file_path) - %w
|
49
|
-
write_entries subdir,
|
45
|
+
def recursively_deflate_directory(disk_file_path, zipfile, zipfile_path)
|
46
|
+
zipfile.mkdir zipfile_path
|
47
|
+
subdir = Dir.entries(disk_file_path) - %w[. ..]
|
48
|
+
write_entries subdir, zipfile_path, zipfile
|
50
49
|
end
|
51
50
|
|
52
|
-
def put_into_archive(disk_file_path,
|
53
|
-
|
54
|
-
f.write(File.open(disk_file_path, 'rb').read)
|
55
|
-
end
|
51
|
+
def put_into_archive(disk_file_path, zipfile, zipfile_path)
|
52
|
+
zipfile.add(zipfile_path, disk_file_path)
|
56
53
|
end
|
57
54
|
end
|
data/samples/gtk_ruby_zip.rb
CHANGED
@@ -31,7 +31,7 @@ class MainApp < Gtk::Window
|
|
31
31
|
sw.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC)
|
32
32
|
box.pack_start(sw, true, true, 0)
|
33
33
|
|
34
|
-
@clist = Gtk::CList.new(%w
|
34
|
+
@clist = Gtk::CList.new(%w[Name Size Compression])
|
35
35
|
@clist.set_selection_mode(Gtk::SELECTION_BROWSE)
|
36
36
|
@clist.set_column_width(0, 120)
|
37
37
|
@clist.set_column_width(1, 120)
|
data/samples/qtzip.rb
CHANGED
@@ -65,7 +65,7 @@ class ZipDialog < ZipDialogUI
|
|
65
65
|
end
|
66
66
|
puts "selected_items.size = #{selected_items.size}"
|
67
67
|
puts "unselected_items.size = #{unselected_items.size}"
|
68
|
-
items = selected_items.
|
68
|
+
items = !selected_items.empty? ? selected_items : unselected_items
|
69
69
|
puts "items.size = #{items.size}"
|
70
70
|
|
71
71
|
d = Qt::FileDialog.get_existing_directory(nil, self)
|
data/samples/zipfind.rb
CHANGED
@@ -31,7 +31,7 @@ module Zip
|
|
31
31
|
end
|
32
32
|
end
|
33
33
|
|
34
|
-
if
|
34
|
+
if $0 == __FILE__
|
35
35
|
module ZipFindConsoleRunner
|
36
36
|
PATH_ARG_INDEX = 0
|
37
37
|
FILENAME_PATTERN_ARG_INDEX = 1
|
@@ -47,7 +47,7 @@ if __FILE__ == $0
|
|
47
47
|
end
|
48
48
|
|
49
49
|
def self.check_args(args)
|
50
|
-
if
|
50
|
+
if args.size != 3
|
51
51
|
usage
|
52
52
|
exit
|
53
53
|
end
|
@@ -2,7 +2,7 @@ require 'test_helper'
|
|
2
2
|
|
3
3
|
class ZipCentralDirectoryEntryTest < MiniTest::Test
|
4
4
|
def test_read_from_stream
|
5
|
-
File.open('test/data/testDirectory.bin', 'rb') do
|
5
|
+
File.open('test/data/testDirectory.bin', 'rb') do |file|
|
6
6
|
entry = ::Zip::Entry.read_c_dir_entry(file)
|
7
7
|
|
8
8
|
assert_equal('longAscii.txt', entry.name)
|
@@ -37,7 +37,7 @@ class ZipCentralDirectoryEntryTest < MiniTest::Test
|
|
37
37
|
assert_equal('', entry.comment)
|
38
38
|
|
39
39
|
entry = ::Zip::Entry.read_c_dir_entry(file)
|
40
|
-
|
40
|
+
assert_nil(entry)
|
41
41
|
# Fields that are not check by this test:
|
42
42
|
# version made by 2 bytes
|
43
43
|
# version needed to extract 2 bytes
|
@@ -18,7 +18,9 @@ class NullEncrypterTest < MiniTest::Test
|
|
18
18
|
end
|
19
19
|
|
20
20
|
def test_encrypt
|
21
|
-
|
21
|
+
assert_nil @encrypter.encrypt(nil)
|
22
|
+
|
23
|
+
['', 'a' * 10, 0xffffffff].each do |data|
|
22
24
|
assert_equal data, @encrypter.encrypt(data)
|
23
25
|
end
|
24
26
|
end
|
@@ -42,7 +44,9 @@ class NullDecrypterTest < MiniTest::Test
|
|
42
44
|
end
|
43
45
|
|
44
46
|
def test_decrypt
|
45
|
-
|
47
|
+
assert_nil @decrypter.decrypt(nil)
|
48
|
+
|
49
|
+
['', 'a' * 10, 0xffffffff].each do |data|
|
46
50
|
assert_equal data, @decrypter.decrypt(data)
|
47
51
|
end
|
48
52
|
end
|
Binary file
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# Based on 'relative2' in https://github.com/jwilk/path-traversal-samples,
|
2
|
+
# but create the local `tmp` folder before adding the symlink. Otherwise
|
3
|
+
# we may bail out before we get to trying to create the file.
|
4
|
+
all: relative1.zip
|
5
|
+
relative1.zip:
|
6
|
+
rm -f $(@)
|
7
|
+
mkdir -p -m 755 tmp/tmp
|
8
|
+
umask 022 && echo moo > moo
|
9
|
+
cd tmp && zip -X ../$(@) tmp tmp/../../moo
|
10
|
+
rm -rf tmp moo
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
data/test/data/rubycode.zip
CHANGED
Binary file
|
data/test/entry_set_test.rb
CHANGED
@@ -76,7 +76,7 @@ class ZipEntrySetTest < MiniTest::Test
|
|
76
76
|
::Zip.case_insensitive_match = false
|
77
77
|
zipEntrySet = ::Zip::EntrySet.new(entries)
|
78
78
|
assert_equal(entries[0], zipEntrySet.find_entry('MiXeDcAsEnAmE'))
|
79
|
-
|
79
|
+
assert_nil(zipEntrySet.find_entry('mixedcasename'))
|
80
80
|
end
|
81
81
|
|
82
82
|
def test_entries_with_sort
|
@@ -123,7 +123,7 @@ class ZipEntrySetTest < MiniTest::Test
|
|
123
123
|
]
|
124
124
|
entrySet = ::Zip::EntrySet.new(entries)
|
125
125
|
|
126
|
-
|
126
|
+
assert_nil(entrySet.parent(entries[0]))
|
127
127
|
assert_equal(entries[0], entrySet.parent(entries[1]))
|
128
128
|
assert_equal(entries[1], entrySet.parent(entries[2]))
|
129
129
|
end
|
@@ -149,4 +149,15 @@ class ZipEntrySetTest < MiniTest::Test
|
|
149
149
|
# assert_equal(entries.size, res.size)
|
150
150
|
# assert_equal(entrySet.map { |e| e.name }, res.map { |e| e.name })
|
151
151
|
end
|
152
|
+
|
153
|
+
def test_glob3
|
154
|
+
entries = [
|
155
|
+
::Zip::Entry.new('zf.zip', 'a/a'),
|
156
|
+
::Zip::Entry.new('zf.zip', 'a/b'),
|
157
|
+
::Zip::Entry.new('zf.zip', 'a/c')
|
158
|
+
]
|
159
|
+
entrySet = ::Zip::EntrySet.new(entries)
|
160
|
+
|
161
|
+
assert_equal(entries[0, 2].sort, entrySet.glob('a/{a,b}').sort)
|
162
|
+
end
|
152
163
|
end
|
data/test/entry_test.rb
CHANGED
@@ -1,16 +1,7 @@
|
|
1
1
|
require 'test_helper'
|
2
2
|
|
3
3
|
class ZipEntryTest < MiniTest::Test
|
4
|
-
|
5
|
-
TEST_COMMENT = 'a comment'
|
6
|
-
TEST_COMPRESSED_SIZE = 1234
|
7
|
-
TEST_CRC = 325_324
|
8
|
-
TEST_EXTRA = 'Some data here'
|
9
|
-
TEST_COMPRESSIONMETHOD = ::Zip::Entry::DEFLATED
|
10
|
-
TEST_NAME = 'entry name'
|
11
|
-
TEST_SIZE = 8432
|
12
|
-
TEST_ISDIRECTORY = false
|
13
|
-
TEST_TIME = Time.now
|
4
|
+
include ZipEntryData
|
14
5
|
|
15
6
|
def test_constructor_and_getters
|
16
7
|
entry = ::Zip::Entry.new(TEST_ZIPFILE,
|
@@ -118,8 +109,8 @@ class ZipEntryTest < MiniTest::Test
|
|
118
109
|
entry5 = ::Zip::Entry.new('zf.zip', 'aa/bb/cc')
|
119
110
|
entry6 = ::Zip::Entry.new('zf.zip', 'aa/bb/cc/')
|
120
111
|
|
121
|
-
|
122
|
-
|
112
|
+
assert_nil(entry1.parent_as_string)
|
113
|
+
assert_nil(entry2.parent_as_string)
|
123
114
|
assert_equal('aa/', entry3.parent_as_string)
|
124
115
|
assert_equal('aa/', entry4.parent_as_string)
|
125
116
|
assert_equal('aa/bb/', entry5.parent_as_string)
|
data/test/errors_test.rb
CHANGED
data/test/file_extract_test.rb
CHANGED
@@ -10,6 +10,10 @@ class ZipFileExtractTest < MiniTest::Test
|
|
10
10
|
::File.delete(EXTRACTED_FILENAME) if ::File.exist?(EXTRACTED_FILENAME)
|
11
11
|
end
|
12
12
|
|
13
|
+
def teardown
|
14
|
+
::Zip.reset!
|
15
|
+
end
|
16
|
+
|
13
17
|
def test_extract
|
14
18
|
::Zip::File.open(TEST_ZIP.zip_name) do |zf|
|
15
19
|
zf.extract(ENTRY_TO_EXTRACT, EXTRACTED_FILENAME)
|
@@ -80,4 +84,62 @@ class ZipFileExtractTest < MiniTest::Test
|
|
80
84
|
end
|
81
85
|
assert(!File.exist?(outFile))
|
82
86
|
end
|
87
|
+
|
88
|
+
def test_extract_incorrect_size
|
89
|
+
# The uncompressed size fields in the zip file cannot be trusted. This makes
|
90
|
+
# it harder for callers to validate the sizes of the files they are
|
91
|
+
# extracting, which can lead to denial of service. See also
|
92
|
+
# https://en.wikipedia.org/wiki/Zip_bomb
|
93
|
+
Dir.mktmpdir do |tmp|
|
94
|
+
real_zip = File.join(tmp, 'real.zip')
|
95
|
+
fake_zip = File.join(tmp, 'fake.zip')
|
96
|
+
file_name = 'a'
|
97
|
+
true_size = 500_000
|
98
|
+
fake_size = 1
|
99
|
+
|
100
|
+
::Zip::File.open(real_zip, ::Zip::File::CREATE) do |zf|
|
101
|
+
zf.get_output_stream(file_name) do |os|
|
102
|
+
os.write 'a' * true_size
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
compressed_size = nil
|
107
|
+
::Zip::File.open(real_zip) do |zf|
|
108
|
+
a_entry = zf.find_entry(file_name)
|
109
|
+
compressed_size = a_entry.compressed_size
|
110
|
+
assert_equal true_size, a_entry.size
|
111
|
+
end
|
112
|
+
|
113
|
+
true_size_bytes = [compressed_size, true_size, file_name.size].pack('LLS')
|
114
|
+
fake_size_bytes = [compressed_size, fake_size, file_name.size].pack('LLS')
|
115
|
+
|
116
|
+
data = File.binread(real_zip)
|
117
|
+
assert data.include?(true_size_bytes)
|
118
|
+
data.gsub! true_size_bytes, fake_size_bytes
|
119
|
+
|
120
|
+
File.open(fake_zip, 'wb') do |file|
|
121
|
+
file.write data
|
122
|
+
end
|
123
|
+
|
124
|
+
Dir.chdir tmp do
|
125
|
+
::Zip::File.open(fake_zip) do |zf|
|
126
|
+
a_entry = zf.find_entry(file_name)
|
127
|
+
assert_equal fake_size, a_entry.size
|
128
|
+
|
129
|
+
::Zip.validate_entry_sizes = false
|
130
|
+
a_entry.extract
|
131
|
+
assert_equal true_size, File.size(file_name)
|
132
|
+
FileUtils.rm file_name
|
133
|
+
|
134
|
+
::Zip.validate_entry_sizes = true
|
135
|
+
error = assert_raises ::Zip::EntrySizeError do
|
136
|
+
a_entry.extract
|
137
|
+
end
|
138
|
+
assert_equal \
|
139
|
+
'Entry a should be 1B but is larger when inflated',
|
140
|
+
error.message
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
83
145
|
end
|
@@ -1,69 +1,65 @@
|
|
1
1
|
require 'test_helper'
|
2
2
|
|
3
3
|
class FilePermissionsTest < MiniTest::Test
|
4
|
-
|
5
|
-
FILENAME = File.join(File.dirname(__FILE__),
|
4
|
+
ZIPNAME = File.join(File.dirname(__FILE__), 'umask.zip')
|
5
|
+
FILENAME = File.join(File.dirname(__FILE__), 'umask.txt')
|
6
6
|
|
7
7
|
def teardown
|
8
|
+
::File.unlink(ZIPNAME)
|
8
9
|
::File.unlink(FILENAME)
|
9
10
|
end
|
10
11
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
def test_windows_perms
|
17
|
-
create_file
|
12
|
+
def test_current_umask
|
13
|
+
create_files
|
14
|
+
assert_matching_permissions FILENAME, ZIPNAME
|
15
|
+
end
|
18
16
|
|
19
|
-
|
17
|
+
def test_umask_000
|
18
|
+
set_umask(0o000) do
|
19
|
+
create_files
|
20
20
|
end
|
21
21
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
DEFAULT_PERMS = 0100666
|
26
|
-
|
27
|
-
def test_current_umask
|
28
|
-
umask = DEFAULT_PERMS - ::File.umask
|
29
|
-
create_file
|
22
|
+
assert_matching_permissions FILENAME, ZIPNAME
|
23
|
+
end
|
30
24
|
|
31
|
-
|
25
|
+
def test_umask_066
|
26
|
+
set_umask(0o066) do
|
27
|
+
create_files
|
32
28
|
end
|
33
29
|
|
34
|
-
|
35
|
-
|
36
|
-
create_file
|
37
|
-
end
|
30
|
+
assert_matching_permissions FILENAME, ZIPNAME
|
31
|
+
end
|
38
32
|
|
39
|
-
|
33
|
+
def test_umask_027
|
34
|
+
set_umask(0o027) do
|
35
|
+
create_files
|
40
36
|
end
|
41
37
|
|
42
|
-
|
43
|
-
|
44
|
-
set_umask(umask) do
|
45
|
-
create_file
|
46
|
-
end
|
47
|
-
|
48
|
-
assert_equal((DEFAULT_PERMS - umask), ::File.stat(FILENAME).mode)
|
49
|
-
end
|
38
|
+
assert_matching_permissions FILENAME, ZIPNAME
|
39
|
+
end
|
50
40
|
|
41
|
+
def assert_matching_permissions(expected_file, actual_file)
|
42
|
+
assert_equal(
|
43
|
+
::File.stat(expected_file).mode.to_s(8).rjust(4, '0'),
|
44
|
+
::File.stat(actual_file).mode.to_s(8).rjust(4, '0')
|
45
|
+
)
|
51
46
|
end
|
52
47
|
|
53
|
-
def
|
54
|
-
::Zip::File.open(
|
55
|
-
zip.comment =
|
48
|
+
def create_files
|
49
|
+
::Zip::File.open(ZIPNAME, ::Zip::File::CREATE) do |zip|
|
50
|
+
zip.comment = 'test'
|
56
51
|
end
|
57
|
-
end
|
58
52
|
|
59
|
-
|
60
|
-
|
61
|
-
begin
|
62
|
-
saved_umask = ::File.umask(umask)
|
63
|
-
yield
|
64
|
-
ensure
|
65
|
-
::File.umask(saved_umask)
|
53
|
+
::File.open(FILENAME, 'w') do |file|
|
54
|
+
file << 'test'
|
66
55
|
end
|
67
56
|
end
|
68
57
|
|
58
|
+
# If anything goes wrong, make sure the umask is restored.
|
59
|
+
def set_umask(umask)
|
60
|
+
saved_umask = ::File.umask(umask)
|
61
|
+
yield
|
62
|
+
ensure
|
63
|
+
::File.umask(saved_umask)
|
64
|
+
end
|
69
65
|
end
|