blob: e8fcea3b797695b005f10d5bee58688ad838ebc8 [file] [log] [blame]
maruelea586f32016-04-05 11:11:33 -07001# Copyright 2014 The LUCI Authors. All rights reserved.
maruelf1f5e2a2016-05-25 17:10:39 -07002# Use of this source code is governed under the Apache License, Version 2.0
3# that can be found in the LICENSE file.
Marc-Antoine Ruel8bee66d2014-08-28 19:02:07 -04004
5"""Understands .isolated files and can do local operations on them."""
6
7import hashlib
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -04008import json
Marc-Antoine Ruel92257792014-08-28 20:51:08 -04009import logging
10import os
Marc-Antoine Ruel8bee66d2014-08-28 19:02:07 -040011import re
Marc-Antoine Ruel92257792014-08-28 20:51:08 -040012import stat
13import sys
14
15from utils import file_path
maruel12e30012015-10-09 11:55:35 -070016from utils import fs
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -040017from utils import tools
Marc-Antoine Ruel8bee66d2014-08-28 19:02:07 -040018
19
20# Version stored and expected in .isolated files.
tansell26de79e2016-11-13 18:41:11 -080021ISOLATED_FILE_VERSION = '1.6'
Marc-Antoine Ruel8bee66d2014-08-28 19:02:07 -040022
23
24# Chunk size to use when doing disk I/O.
25DISK_FILE_CHUNK = 1024 * 1024
26
27
28# Sadly, hashlib uses 'sha1' instead of the standard 'sha-1' so explicitly
29# specify the names here.
30SUPPORTED_ALGOS = {
31 'md5': hashlib.md5,
32 'sha-1': hashlib.sha1,
33 'sha-512': hashlib.sha512,
34}
35
36
37# Used for serialization.
38SUPPORTED_ALGOS_REVERSE = dict((v, k) for k, v in SUPPORTED_ALGOS.iteritems())
39
tansell26de79e2016-11-13 18:41:11 -080040SUPPORTED_FILE_TYPES = ['basic', 'ar', 'tar']
tanselle4288c32016-07-28 09:45:40 -070041
Marc-Antoine Ruel8bee66d2014-08-28 19:02:07 -040042
Marc-Antoine Ruel1e7658c2014-08-28 19:46:39 -040043class IsolatedError(ValueError):
44 """Generic failure to load a .isolated file."""
45 pass
46
47
48class MappingError(OSError):
49 """Failed to recreate the tree."""
50 pass
51
52
Marc-Antoine Ruel8bee66d2014-08-28 19:02:07 -040053def is_valid_hash(value, algo):
54 """Returns if the value is a valid hash for the corresponding algorithm."""
55 size = 2 * algo().digest_size
56 return bool(re.match(r'^[a-fA-F0-9]{%d}$' % size, value))
57
58
59def get_hash_algo(_namespace):
60 """Return hash algorithm class to use when uploading to given |namespace|."""
61 # TODO(vadimsh): Implement this at some point.
62 return hashlib.sha1
63
64
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -040065def is_namespace_with_compression(namespace):
66 """Returns True if given |namespace| stores compressed objects."""
67 return namespace.endswith(('-gzip', '-deflate'))
68
69
Marc-Antoine Ruel8bee66d2014-08-28 19:02:07 -040070def hash_file(filepath, algo):
71 """Calculates the hash of a file without reading it all in memory at once.
72
73 |algo| should be one of hashlib hashing algorithm.
74 """
75 digest = algo()
maruel12e30012015-10-09 11:55:35 -070076 with fs.open(filepath, 'rb') as f:
Marc-Antoine Ruel8bee66d2014-08-28 19:02:07 -040077 while True:
78 chunk = f.read(DISK_FILE_CHUNK)
79 if not chunk:
80 break
81 digest.update(chunk)
82 return digest.hexdigest()
Marc-Antoine Ruel92257792014-08-28 20:51:08 -040083
84
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -040085class IsolatedFile(object):
86 """Represents a single parsed .isolated file."""
Vadim Shtayura7f7459c2014-09-04 13:25:10 -070087
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -040088 def __init__(self, obj_hash, algo):
89 """|obj_hash| is really the sha-1 of the file."""
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -040090 self.obj_hash = obj_hash
91 self.algo = algo
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -040092
93 # Raw data.
94 self.data = {}
95 # A IsolatedFile instance, one per object in self.includes.
96 self.children = []
97
98 # Set once the .isolated file is loaded.
Vadim Shtayura7f7459c2014-09-04 13:25:10 -070099 self._is_loaded = False
100
101 def __repr__(self):
102 return 'IsolatedFile(%s, loaded: %s)' % (self.obj_hash, self._is_loaded)
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400103
104 def load(self, content):
105 """Verifies the .isolated file is valid and loads this object with the json
106 data.
107 """
108 logging.debug('IsolatedFile.load(%s)' % self.obj_hash)
Vadim Shtayura7f7459c2014-09-04 13:25:10 -0700109 assert not self._is_loaded
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400110 self.data = load_isolated(content, self.algo)
111 self.children = [
112 IsolatedFile(i, self.algo) for i in self.data.get('includes', [])
113 ]
Vadim Shtayura7f7459c2014-09-04 13:25:10 -0700114 self._is_loaded = True
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400115
Vadim Shtayura7f7459c2014-09-04 13:25:10 -0700116 @property
117 def is_loaded(self):
118 """Returns True if 'load' was already called."""
119 return self._is_loaded
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400120
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400121
Vadim Shtayura7f7459c2014-09-04 13:25:10 -0700122def walk_includes(isolated):
123 """Walks IsolatedFile include graph and yields IsolatedFile objects.
124
125 Visits root node first, then recursively all children, left to right.
126 Not yet loaded nodes are considered childless.
127 """
128 yield isolated
129 for child in isolated.children:
130 for x in walk_includes(child):
131 yield x
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400132
133
Vadim Shtayurac28b74f2014-10-06 20:00:08 -0700134@tools.profile
Marc-Antoine Ruel92257792014-08-28 20:51:08 -0400135def expand_symlinks(indir, relfile):
136 """Follows symlinks in |relfile|, but treating symlinks that point outside the
137 build tree as if they were ordinary directories/files. Returns the final
138 symlink-free target and a list of paths to symlinks encountered in the
139 process.
140
141 The rule about symlinks outside the build tree is for the benefit of the
142 Chromium OS ebuild, which symlinks the output directory to an unrelated path
143 in the chroot.
144
145 Fails when a directory loop is detected, although in theory we could support
146 that case.
147 """
148 is_directory = relfile.endswith(os.path.sep)
149 done = indir
150 todo = relfile.strip(os.path.sep)
151 symlinks = []
152
153 while todo:
Vadim Shtayura56c17562014-10-07 17:13:34 -0700154 pre_symlink, symlink, post_symlink = file_path.split_at_symlink(done, todo)
Marc-Antoine Ruel92257792014-08-28 20:51:08 -0400155 if not symlink:
156 todo = file_path.fix_native_path_case(done, todo)
157 done = os.path.join(done, todo)
158 break
159 symlink_path = os.path.join(done, pre_symlink, symlink)
160 post_symlink = post_symlink.lstrip(os.path.sep)
161 # readlink doesn't exist on Windows.
162 # pylint: disable=E1101
163 target = os.path.normpath(os.path.join(done, pre_symlink))
164 symlink_target = os.readlink(symlink_path)
165 if os.path.isabs(symlink_target):
166 # Absolute path are considered a normal directories. The use case is
167 # generally someone who puts the output directory on a separate drive.
168 target = symlink_target
169 else:
170 # The symlink itself could be using the wrong path case.
171 target = file_path.fix_native_path_case(target, symlink_target)
172
173 if not os.path.exists(target):
174 raise MappingError(
175 'Symlink target doesn\'t exist: %s -> %s' % (symlink_path, target))
176 target = file_path.get_native_path_case(target)
177 if not file_path.path_starts_with(indir, target):
178 done = symlink_path
179 todo = post_symlink
180 continue
181 if file_path.path_starts_with(target, symlink_path):
182 raise MappingError(
183 'Can\'t map recursive symlink reference %s -> %s' %
184 (symlink_path, target))
185 logging.info('Found symlink: %s -> %s', symlink_path, target)
186 symlinks.append(os.path.relpath(symlink_path, indir))
187 # Treat the common prefix of the old and new paths as done, and start
188 # scanning again.
189 target = target.split(os.path.sep)
190 symlink_path = symlink_path.split(os.path.sep)
191 prefix_length = 0
192 for target_piece, symlink_path_piece in zip(target, symlink_path):
193 if target_piece == symlink_path_piece:
194 prefix_length += 1
195 else:
196 break
197 done = os.path.sep.join(target[:prefix_length])
198 todo = os.path.join(
199 os.path.sep.join(target[prefix_length:]), post_symlink)
200
201 relfile = os.path.relpath(done, indir)
202 relfile = relfile.rstrip(os.path.sep) + is_directory * os.path.sep
203 return relfile, symlinks
204
205
Vadim Shtayurac28b74f2014-10-06 20:00:08 -0700206@tools.profile
Marc-Antoine Ruel92257792014-08-28 20:51:08 -0400207def expand_directory_and_symlink(indir, relfile, blacklist, follow_symlinks):
208 """Expands a single input. It can result in multiple outputs.
209
210 This function is recursive when relfile is a directory.
211
212 Note: this code doesn't properly handle recursive symlink like one created
213 with:
214 ln -s .. foo
215 """
216 if os.path.isabs(relfile):
217 raise MappingError('Can\'t map absolute path %s' % relfile)
218
219 infile = file_path.normpath(os.path.join(indir, relfile))
220 if not infile.startswith(indir):
221 raise MappingError('Can\'t map file %s outside %s' % (infile, indir))
222
223 filepath = os.path.join(indir, relfile)
224 native_filepath = file_path.get_native_path_case(filepath)
225 if filepath != native_filepath:
226 # Special case './'.
227 if filepath != native_filepath + '.' + os.path.sep:
228 # While it'd be nice to enforce path casing on Windows, it's impractical.
229 # Also give up enforcing strict path case on OSX. Really, it's that sad.
230 # The case where it happens is very specific and hard to reproduce:
231 # get_native_path_case(
232 # u'Foo.framework/Versions/A/Resources/Something.nib') will return
233 # u'Foo.framework/Versions/A/resources/Something.nib', e.g. lowercase 'r'.
234 #
235 # Note that this is really something deep in OSX because running
236 # ls Foo.framework/Versions/A
237 # will print out 'Resources', while file_path.get_native_path_case()
238 # returns a lower case 'r'.
239 #
240 # So *something* is happening under the hood resulting in the command 'ls'
241 # and Carbon.File.FSPathMakeRef('path').FSRefMakePath() to disagree. We
242 # have no idea why.
243 if sys.platform not in ('darwin', 'win32'):
244 raise MappingError(
245 'File path doesn\'t equal native file path\n%s != %s' %
246 (filepath, native_filepath))
247
248 symlinks = []
249 if follow_symlinks:
Marc-Antoine Ruela275b292014-11-25 15:17:21 -0500250 try:
251 relfile, symlinks = expand_symlinks(indir, relfile)
252 except OSError:
253 # The file doesn't exist, it will throw below.
254 pass
Marc-Antoine Ruel92257792014-08-28 20:51:08 -0400255
256 if relfile.endswith(os.path.sep):
257 if not os.path.isdir(infile):
258 raise MappingError(
259 '%s is not a directory but ends with "%s"' % (infile, os.path.sep))
260
261 # Special case './'.
262 if relfile.startswith('.' + os.path.sep):
263 relfile = relfile[2:]
264 outfiles = symlinks
265 try:
maruel12e30012015-10-09 11:55:35 -0700266 for filename in fs.listdir(infile):
Marc-Antoine Ruel92257792014-08-28 20:51:08 -0400267 inner_relfile = os.path.join(relfile, filename)
268 if blacklist and blacklist(inner_relfile):
269 continue
270 if os.path.isdir(os.path.join(indir, inner_relfile)):
271 inner_relfile += os.path.sep
272 outfiles.extend(
273 expand_directory_and_symlink(indir, inner_relfile, blacklist,
274 follow_symlinks))
275 return outfiles
276 except OSError as e:
277 raise MappingError(
278 'Unable to iterate over directory %s.\n%s' % (infile, e))
279 else:
280 # Always add individual files even if they were blacklisted.
281 if os.path.isdir(infile):
282 raise MappingError(
283 'Input directory %s must have a trailing slash' % infile)
284
285 if not os.path.isfile(infile):
286 raise MappingError('Input file %s doesn\'t exist' % infile)
287
288 return symlinks + [relfile]
289
290
291def expand_directories_and_symlinks(
292 indir, infiles, blacklist, follow_symlinks, ignore_broken_items):
293 """Expands the directories and the symlinks, applies the blacklist and
294 verifies files exist.
295
296 Files are specified in os native path separator.
297 """
298 outfiles = []
299 for relfile in infiles:
300 try:
301 outfiles.extend(
302 expand_directory_and_symlink(
303 indir, relfile, blacklist, follow_symlinks))
304 except MappingError as e:
305 if not ignore_broken_items:
306 raise
307 logging.info('warning: %s', e)
308 return outfiles
309
310
Vadim Shtayurac28b74f2014-10-06 20:00:08 -0700311@tools.profile
kjlubick80596f02017-04-28 08:13:19 -0700312def file_to_metadata(filepath, prevdict, read_only, algo, collapse_symlinks):
Marc-Antoine Ruel92257792014-08-28 20:51:08 -0400313 """Processes an input file, a dependency, and return meta data about it.
314
315 Behaviors:
316 - Retrieves the file mode, file size, file timestamp, file link
317 destination if it is a file link and calcultate the SHA-1 of the file's
318 content if the path points to a file and not a symlink.
319
320 Arguments:
321 filepath: File to act on.
322 prevdict: the previous dictionary. It is used to retrieve the cached sha-1
323 to skip recalculating the hash. Optional.
324 read_only: If 1 or 2, the file mode is manipulated. In practice, only save
325 one of 4 modes: 0755 (rwx), 0644 (rw), 0555 (rx), 0444 (r). On
326 windows, mode is not set since all files are 'executable' by
327 default.
328 algo: Hashing algorithm used.
kjlubick80596f02017-04-28 08:13:19 -0700329 collapse_symlinks: True if symlinked files should be treated like they were
330 the normal underlying file.
Marc-Antoine Ruel92257792014-08-28 20:51:08 -0400331
332 Returns:
333 The necessary dict to create a entry in the 'files' section of an .isolated
334 file.
335 """
Marc-Antoine Ruelf1d827c2014-11-24 15:22:25 -0500336 # TODO(maruel): None is not a valid value.
337 assert read_only in (None, 0, 1, 2), read_only
Marc-Antoine Ruel92257792014-08-28 20:51:08 -0400338 out = {}
339 # Always check the file stat and check if it is a link. The timestamp is used
340 # to know if the file's content/symlink destination should be looked into.
341 # E.g. only reuse from prevdict if the timestamp hasn't changed.
342 # There is the risk of the file's timestamp being reset to its last value
343 # manually while its content changed. We don't protect against that use case.
344 try:
kjlubick80596f02017-04-28 08:13:19 -0700345 if collapse_symlinks:
346 # os.stat follows symbolic links
347 filestats = os.stat(filepath)
348 else:
349 # os.lstat does not follow symbolic links, and thus preserves them.
350 filestats = os.lstat(filepath)
Marc-Antoine Ruel92257792014-08-28 20:51:08 -0400351 except OSError:
352 # The file is not present.
353 raise MappingError('%s is missing' % filepath)
354 is_link = stat.S_ISLNK(filestats.st_mode)
355
356 if sys.platform != 'win32':
357 # Ignore file mode on Windows since it's not really useful there.
358 filemode = stat.S_IMODE(filestats.st_mode)
359 # Remove write access for group and all access to 'others'.
360 filemode &= ~(stat.S_IWGRP | stat.S_IRWXO)
361 if read_only:
362 filemode &= ~stat.S_IWUSR
Marc-Antoine Ruela275b292014-11-25 15:17:21 -0500363 if filemode & (stat.S_IXUSR|stat.S_IRGRP) == (stat.S_IXUSR|stat.S_IRGRP):
364 # Only keep x group bit if both x user bit and group read bit are set.
Marc-Antoine Ruel92257792014-08-28 20:51:08 -0400365 filemode |= stat.S_IXGRP
366 else:
367 filemode &= ~stat.S_IXGRP
368 if not is_link:
369 out['m'] = filemode
370
371 # Used to skip recalculating the hash or link destination. Use the most recent
372 # update time.
373 out['t'] = int(round(filestats.st_mtime))
374
375 if not is_link:
376 out['s'] = filestats.st_size
377 # If the timestamp wasn't updated and the file size is still the same, carry
378 # on the sha-1.
379 if (prevdict.get('t') == out['t'] and
380 prevdict.get('s') == out['s']):
381 # Reuse the previous hash if available.
382 out['h'] = prevdict.get('h')
383 if not out.get('h'):
384 out['h'] = hash_file(filepath, algo)
385 else:
386 # If the timestamp wasn't updated, carry on the link destination.
387 if prevdict.get('t') == out['t']:
388 # Reuse the previous link destination if available.
389 out['l'] = prevdict.get('l')
390 if out.get('l') is None:
391 # The link could be in an incorrect path case. In practice, this only
392 # happen on OSX on case insensitive HFS.
393 # TODO(maruel): It'd be better if it was only done once, in
394 # expand_directory_and_symlink(), so it would not be necessary to do again
395 # here.
396 symlink_value = os.readlink(filepath) # pylint: disable=E1101
397 filedir = file_path.get_native_path_case(os.path.dirname(filepath))
398 native_dest = file_path.fix_native_path_case(filedir, symlink_value)
399 out['l'] = os.path.relpath(native_dest, filedir)
400 return out
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400401
402
403def save_isolated(isolated, data):
404 """Writes one or multiple .isolated files.
405
406 Note: this reference implementation does not create child .isolated file so it
407 always returns an empty list.
408
409 Returns the list of child isolated files that are included by |isolated|.
410 """
411 # Make sure the data is valid .isolated data by 'reloading' it.
412 algo = SUPPORTED_ALGOS[data['algo']]
413 load_isolated(json.dumps(data), algo)
414 tools.write_json(isolated, data, True)
415 return []
416
417
marueldf6e95e2016-02-26 19:05:38 -0800418def split_path(path):
419 """Splits a path and return a list with each element."""
420 out = []
421 while path:
422 path, rest = os.path.split(path)
423 if rest:
424 out.append(rest)
425 return out
426
427
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400428def load_isolated(content, algo):
429 """Verifies the .isolated file is valid and loads this object with the json
430 data.
431
432 Arguments:
433 - content: raw serialized content to load.
434 - algo: hashlib algorithm class. Used to confirm the algorithm matches the
435 algorithm used on the Isolate Server.
436 """
437 try:
438 data = json.loads(content)
aludwin6b54a6b2017-08-03 18:20:06 -0700439 except ValueError as v:
Adrian Ludwin7dc29dd2017-08-17 23:01:47 -0400440 logging.error('Failed to parse .isolated file:\n%s', content)
aludwin6b54a6b2017-08-03 18:20:06 -0700441 raise IsolatedError('Failed to parse (%s): %s...' % (v, content[:100]))
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400442
443 if not isinstance(data, dict):
444 raise IsolatedError('Expected dict, got %r' % data)
445
446 # Check 'version' first, since it could modify the parsing after.
447 value = data.get('version', '1.0')
448 if not isinstance(value, basestring):
449 raise IsolatedError('Expected string, got %r' % value)
450 try:
451 version = tuple(map(int, value.split('.')))
452 except ValueError:
453 raise IsolatedError('Expected valid version, got %r' % value)
454
455 expected_version = tuple(map(int, ISOLATED_FILE_VERSION.split('.')))
456 # Major version must match.
457 if version[0] != expected_version[0]:
458 raise IsolatedError(
459 'Expected compatible \'%s\' version, got %r' %
460 (ISOLATED_FILE_VERSION, value))
461
462 if algo is None:
463 # TODO(maruel): Remove the default around Jan 2014.
464 # Default the algorithm used in the .isolated file itself, falls back to
465 # 'sha-1' if unspecified.
466 algo = SUPPORTED_ALGOS_REVERSE[data.get('algo', 'sha-1')]
467
468 for key, value in data.iteritems():
469 if key == 'algo':
470 if not isinstance(value, basestring):
471 raise IsolatedError('Expected string, got %r' % value)
472 if value not in SUPPORTED_ALGOS:
473 raise IsolatedError(
474 'Expected one of \'%s\', got %r' %
475 (', '.join(sorted(SUPPORTED_ALGOS)), value))
476 if value != SUPPORTED_ALGOS_REVERSE[algo]:
477 raise IsolatedError(
478 'Expected \'%s\', got %r' % (SUPPORTED_ALGOS_REVERSE[algo], value))
479
480 elif key == 'command':
481 if not isinstance(value, list):
482 raise IsolatedError('Expected list, got %r' % value)
483 if not value:
484 raise IsolatedError('Expected non-empty command')
485 for subvalue in value:
486 if not isinstance(subvalue, basestring):
487 raise IsolatedError('Expected string, got %r' % subvalue)
488
489 elif key == 'files':
490 if not isinstance(value, dict):
491 raise IsolatedError('Expected dict, got %r' % value)
492 for subkey, subvalue in value.iteritems():
493 if not isinstance(subkey, basestring):
494 raise IsolatedError('Expected string, got %r' % subkey)
marueldf6e95e2016-02-26 19:05:38 -0800495 if os.path.isabs(subkey) or subkey.startswith('\\\\'):
496 # Disallow '\\\\', it could UNC on Windows but disallow this
497 # everywhere.
498 raise IsolatedError('File path can\'t be absolute: %r' % subkey)
499 if subkey.endswith(('/', '\\')):
500 raise IsolatedError(
501 'File path can\'t end with \'%s\': %r' % (subkey[-1], subkey))
502 if '..' in split_path(subkey):
503 raise IsolatedError('File path can\'t reference parent: %r' % subkey)
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400504 if not isinstance(subvalue, dict):
505 raise IsolatedError('Expected dict, got %r' % subvalue)
506 for subsubkey, subsubvalue in subvalue.iteritems():
507 if subsubkey == 'l':
508 if not isinstance(subsubvalue, basestring):
509 raise IsolatedError('Expected string, got %r' % subsubvalue)
510 elif subsubkey == 'm':
511 if not isinstance(subsubvalue, int):
512 raise IsolatedError('Expected int, got %r' % subsubvalue)
513 elif subsubkey == 'h':
514 if not is_valid_hash(subsubvalue, algo):
515 raise IsolatedError('Expected sha-1, got %r' % subsubvalue)
516 elif subsubkey == 's':
517 if not isinstance(subsubvalue, (int, long)):
518 raise IsolatedError('Expected int or long, got %r' % subsubvalue)
tanselle4288c32016-07-28 09:45:40 -0700519 elif subsubkey == 't':
520 if subsubvalue not in SUPPORTED_FILE_TYPES:
521 raise IsolatedError('Expected one of \'%s\', got %r' % (
522 ', '.join(sorted(SUPPORTED_FILE_TYPES)), subsubvalue))
Marc-Antoine Ruel52436aa2014-08-28 21:57:57 -0400523 else:
524 raise IsolatedError('Unknown subsubkey %s' % subsubkey)
525 if bool('h' in subvalue) == bool('l' in subvalue):
526 raise IsolatedError(
527 'Need only one of \'h\' (sha-1) or \'l\' (link), got: %r' %
528 subvalue)
529 if bool('h' in subvalue) != bool('s' in subvalue):
530 raise IsolatedError(
531 'Both \'h\' (sha-1) and \'s\' (size) should be set, got: %r' %
532 subvalue)
533 if bool('s' in subvalue) == bool('l' in subvalue):
534 raise IsolatedError(
535 'Need only one of \'s\' (size) or \'l\' (link), got: %r' %
536 subvalue)
537 if bool('l' in subvalue) and bool('m' in subvalue):
538 raise IsolatedError(
539 'Cannot use \'m\' (mode) and \'l\' (link), got: %r' %
540 subvalue)
541
542 elif key == 'includes':
543 if not isinstance(value, list):
544 raise IsolatedError('Expected list, got %r' % value)
545 if not value:
546 raise IsolatedError('Expected non-empty includes list')
547 for subvalue in value:
548 if not is_valid_hash(subvalue, algo):
549 raise IsolatedError('Expected sha-1, got %r' % subvalue)
550
551 elif key == 'os':
552 if version >= (1, 4):
553 raise IsolatedError('Key \'os\' is not allowed starting version 1.4')
554
555 elif key == 'read_only':
556 if not value in (0, 1, 2):
557 raise IsolatedError('Expected 0, 1 or 2, got %r' % value)
558
559 elif key == 'relative_cwd':
560 if not isinstance(value, basestring):
561 raise IsolatedError('Expected string, got %r' % value)
562
563 elif key == 'version':
564 # Already checked above.
565 pass
566
567 else:
568 raise IsolatedError('Unknown key %r' % key)
569
570 # Automatically fix os.path.sep if necessary. While .isolated files are always
571 # in the the native path format, someone could want to download an .isolated
572 # tree from another OS.
573 wrong_path_sep = '/' if os.path.sep == '\\' else '\\'
574 if 'files' in data:
575 data['files'] = dict(
576 (k.replace(wrong_path_sep, os.path.sep), v)
577 for k, v in data['files'].iteritems())
578 for v in data['files'].itervalues():
579 if 'l' in v:
580 v['l'] = v['l'].replace(wrong_path_sep, os.path.sep)
581 if 'relative_cwd' in data:
582 data['relative_cwd'] = data['relative_cwd'].replace(
583 wrong_path_sep, os.path.sep)
584 return data