6277: valid manifest must end with new line.
[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 => e
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.strip)
51       unless m
52         raise ArgumentError.new "not a valid locator #{tok}"
53       end
54
55       tokhash, _, toksize, _, trailer = m[1..5]
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 "unknown 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     STREAM_REGEXP = /(\.)(\/+.*)*$/
101     FILE_REGEXP = /^[[:digit:]]+:[[:digit:]]+:/
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 as per the manifest format definition.
232     # Valid format: stream name + one or more locators + one or more files for each stream in manifest.
233     # https://arvados.org/projects/arvados/wiki/Keep_manifest_format
234     def self.valid?(manifest)
235       raise ArgumentError.new "Invalid manifest: does not end with new line" if !manifest.end_with?("\n")
236       line_count = 0
237       manifest.each_line do |line|
238         line_count += 1
239
240         words = line.split
241
242         count = 0
243         word = words.shift
244         count += 1 if word =~ STREAM_REGEXP
245         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing or invalid stream name #{word.inspect}" if count != 1
246
247         count = 0
248         word = words.shift
249         while word =~ Locator::LOCATOR_REGEXP
250           word = words.shift
251           count += 1
252         end
253         raise ArgumentError.new "Manifest invalid for stream #{line_count}: missing or invalid locator #{word.inspect}" if count == 0
254
255         count = 0
256         while word =~ FILE_REGEXP
257           word = words.shift
258           count += 1
259         end
260
261         if word
262           raise ArgumentError.new "Manifest invalid for stream #{line_count}: invalid file token #{word.inspect}"
263         elsif count == 0
264           raise ArgumentError.new "Manifest invalid for stream #{line_count}: no file tokens"
265         end
266       end
267     end
268   end
269 end