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