14482: Handle escaped chars on manifest validation.
[arvados.git] / sdk / ruby / lib / arvados / keep.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: Apache-2.0
4
5 module Keep
6   class Locator
7     # A Locator is used to parse and manipulate Keep locator strings.
8     #
9     # Locators obey the following syntax:
10     #
11     #   locator      ::= address hint*
12     #   address      ::= digest size-hint
13     #   digest       ::= <32 hexadecimal digits>
14     #   size-hint    ::= "+" [0-9]+
15     #   hint         ::= "+" hint-type hint-content
16     #   hint-type    ::= [A-Z]
17     #   hint-content ::= [A-Za-z0-9@_-]+
18     #
19     # Individual hints may have their own required format:
20     #
21     #   sign-hint      ::= "+A" <40 lowercase hex digits> "@" sign-timestamp
22     #   sign-timestamp ::= <8 lowercase hex digits>
23     attr_reader :hash, :hints, :size
24
25     LOCATOR_REGEXP = /^([[:xdigit:]]{32})(\+([[:digit:]]+))?((\+([[:upper:]][[:alnum:]@_-]*))+)?\z/
26
27     def initialize(hasharg, sizearg, hintarg)
28       @hash = hasharg
29       @size = sizearg
30       @hints = hintarg
31     end
32
33     def self.valid? tok
34       !!(LOCATOR_REGEXP.match tok)
35     end
36
37     # Locator.parse returns a Locator object parsed from the string tok.
38     # Returns nil if tok could not be parsed as a valid locator.
39     def self.parse(tok)
40       begin
41         Locator.parse!(tok)
42       rescue ArgumentError
43         nil
44       end
45     end
46
47     # Locator.parse! returns a Locator object parsed from the string tok,
48     # raising an ArgumentError if tok cannot be parsed.
49     def self.parse!(tok)
50       if tok.nil? or tok.empty?
51         raise ArgumentError.new "locator is nil or empty"
52       end
53
54       m = LOCATOR_REGEXP.match(tok)
55       unless m
56         raise ArgumentError.new "not a valid locator #{tok}"
57       end
58
59       tokhash, _, toksize, _, _, trailer = m[1..6]
60       tokhints = []
61       if trailer
62         trailer.split('+').each do |hint|
63           if hint =~ /^[[:upper:]][[:alnum:]@_-]*$/
64             tokhints.push(hint)
65           else
66             raise ArgumentError.new "invalid hint #{hint}"
67           end
68         end
69       end
70
71       Locator.new(tokhash, toksize, tokhints)
72     end
73
74     # Returns the signature hint supplied with this locator,
75     # or nil if the locator was not signed.
76     def signature
77       @hints.grep(/^A/).first
78     end
79
80     # Returns an unsigned Locator.
81     def without_signature
82       Locator.new(@hash, @size, @hints.reject { |o| o.start_with?("A") })
83     end
84
85     def strip_hints
86       Locator.new(@hash, @size, [])
87     end
88
89     def strip_hints!
90       @hints = []
91       self
92     end
93
94     def to_s
95       if @size
96         [ @hash, @size, *@hints ].join('+')
97       else
98         [ @hash, *@hints ].join('+')
99       end
100     end
101   end
102
103   class Manifest
104     STRICT_STREAM_TOKEN_REGEXP = /^(\.)(\/[^\/\s]+)*$/
105     STRICT_FILE_TOKEN_REGEXP = /^[[:digit:]]+:[[:digit:]]+:([^\s\/]+(\/[^\s\/]+)*)$/
106     EMPTY_DOT_FILE_TOKEN_REGEXP = /^0:0:\.$/
107
108     # Class to parse a manifest text and provide common views of that data.
109     def initialize(manifest_text)
110       @text = manifest_text
111       @files = nil
112     end
113
114     def each_line
115       return to_enum(__method__) unless block_given?
116       @text.each_line do |line|
117         stream_name = nil
118         block_tokens = []
119         file_tokens = []
120         line.scan(/\S+/) do |token|
121           if stream_name.nil?
122             stream_name = unescape token
123           elsif file_tokens.empty? and Locator.valid? token
124             block_tokens << token
125           else
126             file_tokens << unescape(token)
127           end
128         end
129         # Ignore blank lines
130         next if stream_name.nil?
131         yield [stream_name, block_tokens, file_tokens]
132       end
133     end
134
135     def self.unescape(s, except=[])
136       return nil if s.nil?
137
138       # Parse backslash escapes in a Keep manifest stream or file name.
139       s.gsub(/\\(\\|[0-7]{3})/) do |_|
140         if $1 == '\\'
141           '\\'
142         elsif except.include? $1
143           $1
144         else
145           $1.to_i(8).chr
146         end
147       end
148     end
149
150     def unescape(s)
151       self.class.unescape(s)
152     end
153
154     def split_file_token token
155       start_pos, filesize, filename = token.split(':', 3)
156       if filename.nil?
157         raise ArgumentError.new "Invalid file token '#{token}'"
158       end
159       [start_pos.to_i, filesize.to_i, unescape(filename)]
160     end
161
162     def each_file_spec
163       return to_enum(__method__) unless block_given?
164       @text.each_line do |line|
165         stream_name = nil
166         in_file_tokens = false
167         line.scan(/\S+/) do |token|
168           if stream_name.nil?
169             stream_name = unescape token
170           elsif in_file_tokens or not Locator.valid? token
171             in_file_tokens = true
172
173             start_pos, file_size, file_name = split_file_token(token)
174             stream_name_adjuster = ''
175             if file_name.include?('/')                # '/' in filename
176               dirname, sep, basename = file_name.rpartition('/')
177               stream_name_adjuster = sep + dirname   # /dir_parts
178               file_name = basename
179             end
180
181             yield [stream_name + stream_name_adjuster, start_pos, file_size, file_name]
182           end
183         end
184       end
185       true
186     end
187
188     def files
189       if @files.nil?
190         file_sizes = Hash.new(0)
191         each_file_spec do |streamname, _, filesize, filename|
192           file_sizes[[streamname, filename]] += filesize
193         end
194         @files = file_sizes.each_pair.map do |(streamname, filename), size|
195           [streamname, filename, size]
196         end
197       end
198       @files
199     end
200
201     def files_count(stop_after=nil)
202       # Return the number of files represented in this manifest.
203       # If stop_after is provided, files_count will read the manifest
204       # incrementally, and return immediately when it counts that number of
205       # files.  This can help you avoid parsing the entire manifest if you
206       # just want to check if a small number of files are specified.
207       if stop_after.nil? or not @files.nil?
208         return files.size
209       end
210       seen_files = {}
211       each_file_spec do |streamname, _, _, filename|
212         seen_files[[streamname, filename]] = true
213         return stop_after if (seen_files.size >= stop_after)
214       end
215       seen_files.size
216     end
217
218     def files_size
219       # Return the total size of all files in this manifest.
220       files.reduce(0) { |total, (_, _, size)| total + size }
221     end
222
223     def exact_file_count?(want_count)
224       files_count(want_count + 1) == want_count
225     end
226
227     def minimum_file_count?(want_count)
228       files_count(want_count) >= want_count
229     end
230
231     def has_file?(want_stream, want_file=nil)
232       if want_file.nil?
233         want_stream, want_file = File.split(want_stream)
234       end
235       each_file_spec do |streamname, _, _, name|
236         if streamname == want_stream and name == want_file
237           return true
238         end
239       end
240       false
241     end
242
243     # Verify that a given manifest is valid according to
244     # https://arvados.org/projects/arvados/wiki/Keep_manifest_format
245     def self.validate! manifest
246       raise ArgumentError.new "No manifest found" if !manifest
247
248       return true if manifest.empty?
249
250       raise ArgumentError.new "Invalid manifest: does not end with newline" if !manifest.end_with?("\n")
251       line_count = 0
252       manifest.each_line do |line|
253         line_count += 1
254
255         words = line[0..-2].split(/ /)
256         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing stream name" if words.empty?
257
258         count = 0
259
260         word = words.shift
261         unescaped_word = unescape(word, except=["040"])
262         count += 1 if unescaped_word =~ STRICT_STREAM_TOKEN_REGEXP and unescaped_word !~ /\/\.\.?(\/|$)/
263         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing or invalid stream name #{word.inspect if word}" if count != 1
264
265         count = 0
266         word = words.shift
267         while word =~ Locator::LOCATOR_REGEXP
268           word = words.shift
269           count += 1
270         end
271         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing or invalid locator #{word.inspect if word}" if count == 0
272
273         count = 0
274         while unescape(word) =~ EMPTY_DOT_FILE_TOKEN_REGEXP or
275           (unescape(word, except=["040"]) =~ STRICT_FILE_TOKEN_REGEXP and ($~[1].split('/') & ['..', '.']).empty?)
276           word = words.shift
277           count += 1
278         end
279
280         if word
281           raise ArgumentError.new "Manifest invalid for stream #{line_count}: invalid file token #{word.inspect}"
282         elsif count == 0
283           raise ArgumentError.new "Manifest invalid for stream #{line_count}: no file tokens"
284         end
285
286         # Ruby's split() method silently drops trailing empty tokens
287         # (which are not allowed by the manifest format) so we have to
288         # check trailing spaces manually.
289         raise ArgumentError.new "Manifest invalid for stream #{line_count}: trailing space" if line.end_with? " \n"
290       end
291       true
292     end
293
294     def self.valid? manifest
295       begin
296         validate! manifest
297         true
298       rescue ArgumentError
299         false
300       end
301     end
302   end
303 end