14482: Allow unescaped " " on stream and file token regexes (WIP)
[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 = /^(\.)(\/[^\/\t\v\n\r]+)*$/
105     STRICT_FILE_TOKEN_REGEXP = /^[[:digit:]]+:[[:digit:]]+:([^\t\v\n\r\/]+(\/[^\t\v\n\r\/]+)*)$/
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)
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         case $1
141         when '\\'
142           '\\'
143         else
144           $1.to_i(8).chr
145         end
146       end
147     end
148
149     def unescape(s)
150       self.class.unescape(s)
151     end
152
153     def split_file_token token
154       start_pos, filesize, filename = token.split(':', 3)
155       if filename.nil?
156         raise ArgumentError.new "Invalid file token '#{token}'"
157       end
158       [start_pos.to_i, filesize.to_i, unescape(filename)]
159     end
160
161     def each_file_spec
162       return to_enum(__method__) unless block_given?
163       @text.each_line do |line|
164         stream_name = nil
165         in_file_tokens = false
166         line.scan(/\S+/) do |token|
167           if stream_name.nil?
168             stream_name = unescape token
169           elsif in_file_tokens or not Locator.valid? token
170             in_file_tokens = true
171
172             start_pos, file_size, file_name = split_file_token(token)
173             stream_name_adjuster = ''
174             if file_name.include?('/')                # '/' in filename
175               dirname, sep, basename = file_name.rpartition('/')
176               stream_name_adjuster = sep + dirname   # /dir_parts
177               file_name = basename
178             end
179
180             yield [stream_name + stream_name_adjuster, start_pos, file_size, file_name]
181           end
182         end
183       end
184       true
185     end
186
187     def files
188       if @files.nil?
189         file_sizes = Hash.new(0)
190         each_file_spec do |streamname, _, filesize, filename|
191           file_sizes[[streamname, filename]] += filesize
192         end
193         @files = file_sizes.each_pair.map do |(streamname, filename), size|
194           [streamname, filename, size]
195         end
196       end
197       @files
198     end
199
200     def files_count(stop_after=nil)
201       # Return the number of files represented in this manifest.
202       # If stop_after is provided, files_count will read the manifest
203       # incrementally, and return immediately when it counts that number of
204       # files.  This can help you avoid parsing the entire manifest if you
205       # just want to check if a small number of files are specified.
206       if stop_after.nil? or not @files.nil?
207         # Avoid counting empty dir placeholders
208         return files.reject{|_, name, size| name == '.' and size == 0}.size
209       end
210       seen_files = {}
211       each_file_spec do |streamname, _, filesize, filename|
212         # Avoid counting empty dir placeholders
213         next if filename == "." and filesize == 0
214         seen_files[[streamname, filename]] = true
215         return stop_after if (seen_files.size >= stop_after)
216       end
217       seen_files.size
218     end
219
220     def files_size
221       # Return the total size of all files in this manifest.
222       files.reduce(0) { |total, (_, _, size)| total + size }
223     end
224
225     def exact_file_count?(want_count)
226       files_count(want_count + 1) == want_count
227     end
228
229     def minimum_file_count?(want_count)
230       files_count(want_count) >= want_count
231     end
232
233     def has_file?(want_stream, want_file=nil)
234       if want_file.nil?
235         want_stream, want_file = File.split(want_stream)
236       end
237       each_file_spec do |streamname, _, _, name|
238         if streamname == want_stream and name == want_file
239           return true
240         end
241       end
242       false
243     end
244
245     # Verify that a given manifest is valid according to
246     # https://arvados.org/projects/arvados/wiki/Keep_manifest_format
247     def self.validate! manifest
248       raise ArgumentError.new "No manifest found" if !manifest
249
250       return true if manifest.empty?
251
252       raise ArgumentError.new "Invalid manifest: does not end with newline" if !manifest.end_with?("\n")
253       line_count = 0
254       manifest.each_line do |line|
255         line_count += 1
256
257         words = line[0..-2].split(/ /)
258         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing stream name" if words.empty?
259
260         count = 0
261
262         word = words.shift
263         unescaped_word = unescape(word)
264         count += 1 if unescaped_word =~ STRICT_STREAM_TOKEN_REGEXP and unescaped_word !~ /\/\.\.?(\/|$)/
265         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing or invalid stream name #{word.inspect if word}" if count != 1
266
267         count = 0
268         word = words.shift
269         while word =~ Locator::LOCATOR_REGEXP
270           word = words.shift
271           count += 1
272         end
273         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing or invalid locator #{word.inspect if word}" if count == 0
274
275         count = 0
276         while unescape(word) =~ EMPTY_DOT_FILE_TOKEN_REGEXP or
277           (unescape(word) =~ STRICT_FILE_TOKEN_REGEXP and ($~[1].split('/') & ['..', '.']).empty?)
278           word = words.shift
279           count += 1
280         end
281
282         if word
283           raise ArgumentError.new "Manifest invalid for stream #{line_count}: invalid file token #{word.inspect}"
284         elsif count == 0
285           raise ArgumentError.new "Manifest invalid for stream #{line_count}: no file tokens"
286         end
287
288         # Ruby's split() method silently drops trailing empty tokens
289         # (which are not allowed by the manifest format) so we have to
290         # check trailing spaces manually.
291         raise ArgumentError.new "Manifest invalid for stream #{line_count}: trailing space" if line.end_with? " \n"
292       end
293       true
294     end
295
296     def self.valid? manifest
297       begin
298         validate! manifest
299         true
300       rescue ArgumentError
301         false
302       end
303     end
304   end
305 end