Merge branch 'master' into 3198-writable-fuse
[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:]+@_-]*))?$/
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     # Class to parse a manifest text and provide common views of that data.
101     def initialize(manifest_text)
102       @text = manifest_text
103       @files = nil
104     end
105
106     def each_line
107       return to_enum(__method__) unless block_given?
108       @text.each_line do |line|
109         stream_name = nil
110         block_tokens = []
111         file_tokens = []
112         line.scan /\S+/ do |token|
113           if stream_name.nil?
114             stream_name = unescape token
115           elsif file_tokens.empty? and Locator.valid? token
116             block_tokens << token
117           else
118             file_tokens << unescape(token)
119           end
120         end
121         # Ignore blank lines
122         next if stream_name.nil?
123         yield [stream_name, block_tokens, file_tokens]
124       end
125     end
126
127     def unescape(s)
128       # Parse backslash escapes in a Keep manifest stream or file name.
129       s.gsub(/\\(\\|[0-7]{3})/) do |_|
130         case $1
131         when '\\'
132           '\\'
133         else
134           $1.to_i(8).chr
135         end
136       end
137     end
138
139     def split_file_token token
140       start_pos, filesize, filename = token.split(':', 3)
141       if filename.nil?
142         raise ArgumentError.new "Invalid file token '#{token}'"
143       end
144       [start_pos.to_i, filesize.to_i, unescape(filename)]
145     end
146
147     def each_file_spec
148       return to_enum(__method__) unless block_given?
149       @text.each_line do |line|
150         stream_name = nil
151         in_file_tokens = false
152         line.scan /\S+/ do |token|
153           if stream_name.nil?
154             stream_name = unescape token
155           elsif in_file_tokens or not Locator.valid? token
156             in_file_tokens = true
157
158             file_tokens = split_file_token(token)
159             stream_name_adjuster = ''
160             if file_tokens[2].include?('/')                # '/' in filename
161               parts = file_tokens[2].rpartition('/')
162               stream_name_adjuster = parts[1] + parts[0]   # /dir_parts
163               file_tokens[2] = parts[2]
164             end
165
166             yield [stream_name + stream_name_adjuster] + file_tokens
167           end
168         end
169       end
170       true
171     end
172
173     def files
174       if @files.nil?
175         file_sizes = Hash.new(0)
176         each_file_spec do |streamname, _, filesize, filename|
177           file_sizes[[streamname, filename]] += filesize
178         end
179         @files = file_sizes.each_pair.map do |(streamname, filename), size|
180           [streamname, filename, size]
181         end
182       end
183       @files
184     end
185
186     def files_count(stop_after=nil)
187       # Return the number of files represented in this manifest.
188       # If stop_after is provided, files_count will read the manifest
189       # incrementally, and return immediately when it counts that number of
190       # files.  This can help you avoid parsing the entire manifest if you
191       # just want to check if a small number of files are specified.
192       if stop_after.nil? or not @files.nil?
193         return files.size
194       end
195       seen_files = {}
196       each_file_spec do |streamname, _, _, filename|
197         seen_files[[streamname, filename]] = true
198         return stop_after if (seen_files.size >= stop_after)
199       end
200       seen_files.size
201     end
202
203     def files_size
204       # Return the total size of all files in this manifest.
205       files.reduce(0) { |total, (_, _, size)| total + size }
206     end
207
208     def exact_file_count?(want_count)
209       files_count(want_count + 1) == want_count
210     end
211
212     def minimum_file_count?(want_count)
213       files_count(want_count) >= want_count
214     end
215
216     def has_file?(want_stream, want_file=nil)
217       if want_file.nil?
218         want_stream, want_file = File.split(want_stream)
219       end
220       each_file_spec do |streamname, _, _, name|
221         if streamname == want_stream and name == want_file
222           return true
223         end
224       end
225       false
226     end
227   end
228 end