Merge branch '8784-dir-listings'
[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
107     # Class to parse a manifest text and provide common views of that data.
108     def initialize(manifest_text)
109       @text = manifest_text
110       @files = nil
111     end
112
113     def each_line
114       return to_enum(__method__) unless block_given?
115       @text.each_line do |line|
116         stream_name = nil
117         block_tokens = []
118         file_tokens = []
119         line.scan(/\S+/) do |token|
120           if stream_name.nil?
121             stream_name = unescape token
122           elsif file_tokens.empty? and Locator.valid? token
123             block_tokens << token
124           else
125             file_tokens << unescape(token)
126           end
127         end
128         # Ignore blank lines
129         next if stream_name.nil?
130         yield [stream_name, block_tokens, file_tokens]
131       end
132     end
133
134     def unescape(s)
135       # Parse backslash escapes in a Keep manifest stream or file name.
136       s.gsub(/\\(\\|[0-7]{3})/) do |_|
137         case $1
138         when '\\'
139           '\\'
140         else
141           $1.to_i(8).chr
142         end
143       end
144     end
145
146     def split_file_token token
147       start_pos, filesize, filename = token.split(':', 3)
148       if filename.nil?
149         raise ArgumentError.new "Invalid file token '#{token}'"
150       end
151       [start_pos.to_i, filesize.to_i, unescape(filename)]
152     end
153
154     def each_file_spec
155       return to_enum(__method__) unless block_given?
156       @text.each_line do |line|
157         stream_name = nil
158         in_file_tokens = false
159         line.scan(/\S+/) do |token|
160           if stream_name.nil?
161             stream_name = unescape token
162           elsif in_file_tokens or not Locator.valid? token
163             in_file_tokens = true
164
165             file_tokens = split_file_token(token)
166             stream_name_adjuster = ''
167             if file_tokens[2].include?('/')                # '/' in filename
168               parts = file_tokens[2].rpartition('/')
169               stream_name_adjuster = parts[1] + parts[0]   # /dir_parts
170               file_tokens[2] = parts[2]
171             end
172
173             yield [stream_name + stream_name_adjuster] + file_tokens
174           end
175         end
176       end
177       true
178     end
179
180     def files
181       if @files.nil?
182         file_sizes = Hash.new(0)
183         each_file_spec do |streamname, _, filesize, filename|
184           file_sizes[[streamname, filename]] += filesize
185         end
186         @files = file_sizes.each_pair.map do |(streamname, filename), size|
187           [streamname, filename, size]
188         end
189       end
190       @files
191     end
192
193     def files_count(stop_after=nil)
194       # Return the number of files represented in this manifest.
195       # If stop_after is provided, files_count will read the manifest
196       # incrementally, and return immediately when it counts that number of
197       # files.  This can help you avoid parsing the entire manifest if you
198       # just want to check if a small number of files are specified.
199       if stop_after.nil? or not @files.nil?
200         return files.size
201       end
202       seen_files = {}
203       each_file_spec do |streamname, _, _, filename|
204         seen_files[[streamname, filename]] = true
205         return stop_after if (seen_files.size >= stop_after)
206       end
207       seen_files.size
208     end
209
210     def files_size
211       # Return the total size of all files in this manifest.
212       files.reduce(0) { |total, (_, _, size)| total + size }
213     end
214
215     def exact_file_count?(want_count)
216       files_count(want_count + 1) == want_count
217     end
218
219     def minimum_file_count?(want_count)
220       files_count(want_count) >= want_count
221     end
222
223     def has_file?(want_stream, want_file=nil)
224       if want_file.nil?
225         want_stream, want_file = File.split(want_stream)
226       end
227       each_file_spec do |streamname, _, _, name|
228         if streamname == want_stream and name == want_file
229           return true
230         end
231       end
232       false
233     end
234
235     # Verify that a given manifest is valid according to
236     # https://arvados.org/projects/arvados/wiki/Keep_manifest_format
237     def self.validate! manifest
238       raise ArgumentError.new "No manifest found" if !manifest
239
240       return true if manifest.empty?
241
242       raise ArgumentError.new "Invalid manifest: does not end with newline" if !manifest.end_with?("\n")
243       line_count = 0
244       manifest.each_line do |line|
245         line_count += 1
246
247         words = line[0..-2].split(/ /)
248         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing stream name" if words.empty?
249
250         count = 0
251
252         word = words.shift
253         count += 1 if word =~ STRICT_STREAM_TOKEN_REGEXP and word !~ /\/\.\.?(\/|$)/
254         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing or invalid stream name #{word.inspect if word}" if count != 1
255
256         count = 0
257         word = words.shift
258         while word =~ Locator::LOCATOR_REGEXP
259           word = words.shift
260           count += 1
261         end
262         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing or invalid locator #{word.inspect if word}" if count == 0
263
264         count = 0
265         while word =~ STRICT_FILE_TOKEN_REGEXP and ($~[1].split('/') & ['..','.']).empty?
266           word = words.shift
267           count += 1
268         end
269
270         if word
271           raise ArgumentError.new "Manifest invalid for stream #{line_count}: invalid file token #{word.inspect}"
272         elsif count == 0
273           raise ArgumentError.new "Manifest invalid for stream #{line_count}: no file tokens"
274         end
275
276         # Ruby's split() method silently drops trailing empty tokens
277         # (which are not allowed by the manifest format) so we have to
278         # check trailing spaces manually.
279         raise ArgumentError.new "Manifest invalid for stream #{line_count}: trailing space" if line.end_with? " \n"
280       end
281       true
282     end
283
284     def self.valid? manifest
285       begin
286         validate! manifest
287         true
288       rescue ArgumentError
289         false
290       end
291     end
292   end
293 end