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