d271610339f98aef135b996b6512e13579473a77
[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         # Avoid counting empty dir placeholders
209         return files.reject{|_, name, size| name == '.' and size == 0}.size
210       end
211       seen_files = {}
212       each_file_spec do |streamname, _, filesize, filename|
213         # Avoid counting empty dir placeholders
214         next if filename == "." and filesize == 0
215         seen_files[[streamname, filename]] = true
216         return stop_after if (seen_files.size >= stop_after)
217       end
218       seen_files.size
219     end
220
221     def files_size
222       # Return the total size of all files in this manifest.
223       files.reduce(0) { |total, (_, _, size)| total + size }
224     end
225
226     def exact_file_count?(want_count)
227       files_count(want_count + 1) == want_count
228     end
229
230     def minimum_file_count?(want_count)
231       files_count(want_count) >= want_count
232     end
233
234     def has_file?(want_stream, want_file=nil)
235       if want_file.nil?
236         want_stream, want_file = File.split(want_stream)
237       end
238       each_file_spec do |streamname, _, _, name|
239         if streamname == want_stream and name == want_file
240           return true
241         end
242       end
243       false
244     end
245
246     # Verify that a given manifest is valid according to
247     # https://arvados.org/projects/arvados/wiki/Keep_manifest_format
248     def self.validate! manifest
249       raise ArgumentError.new "No manifest found" if !manifest
250
251       return true if manifest.empty?
252
253       raise ArgumentError.new "Invalid manifest: does not end with newline" if !manifest.end_with?("\n")
254       line_count = 0
255       manifest.each_line do |line|
256         line_count += 1
257
258         words = line[0..-2].split(/ /)
259         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing stream name" if words.empty?
260
261         count = 0
262
263         word = words.shift
264         unescaped_word = unescape(word, except=["040"])
265         count += 1 if unescaped_word =~ STRICT_STREAM_TOKEN_REGEXP and unescaped_word !~ /\/\.\.?(\/|$)/
266         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing or invalid stream name #{word.inspect if word}" if count != 1
267
268         count = 0
269         word = words.shift
270         while word =~ Locator::LOCATOR_REGEXP
271           word = words.shift
272           count += 1
273         end
274         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing or invalid locator #{word.inspect if word}" if count == 0
275
276         count = 0
277         while unescape(word) =~ EMPTY_DOT_FILE_TOKEN_REGEXP or
278           (unescape(word, except=["040"]) =~ STRICT_FILE_TOKEN_REGEXP and ($~[1].split('/') & ['..', '.']).empty?)
279           word = words.shift
280           count += 1
281         end
282
283         if word
284           raise ArgumentError.new "Manifest invalid for stream #{line_count}: invalid file token #{word.inspect}"
285         elsif count == 0
286           raise ArgumentError.new "Manifest invalid for stream #{line_count}: no file tokens"
287         end
288
289         # Ruby's split() method silently drops trailing empty tokens
290         # (which are not allowed by the manifest format) so we have to
291         # check trailing spaces manually.
292         raise ArgumentError.new "Manifest invalid for stream #{line_count}: trailing space" if line.end_with? " \n"
293       end
294       true
295     end
296
297     def self.valid? manifest
298       begin
299         validate! manifest
300         true
301       rescue ArgumentError
302         false
303       end
304     end
305   end
306 end