Bug 784841 - Part 2: Implement sandboxing for Python build files; r=ted,glandium
authorGregory Szorc <gps@mozilla.com>
Tue, 15 Jan 2013 22:21:21 -0800
changeset 128920 ec072cee05025335f0637de7e1dab95623fe8a56
parent 128919 a9021c50ccf944cfe1d3184d8ae5ac9081338232
child 128921 d8ea5b8be44d077721bd272cbe9e027d892eeedc
push id2323
push userbbajaj@mozilla.com
push dateMon, 01 Apr 2013 19:47:02 +0000
treeherdermozilla-beta@7712be144d91 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersted, glandium
bugs784841
milestone21.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 784841 - Part 2: Implement sandboxing for Python build files; r=ted,glandium This is the beginning of Mozilla's new build system. In this patch, we have a Python sandbox tailored for execution of Python scripts which will define the build system. We also have a build reader that traverses a linked set of scripts. More details are available in the thorough README.rst files as part of this patch. * * * Bug 784841 - Part 2b: Option to not descend into child moz.build files; r=ted
mach
python/Makefile.in
python/mozbuild/README.rst
python/mozbuild/TODO
python/mozbuild/mozbuild/frontend/README.rst
python/mozbuild/mozbuild/frontend/__init__.py
python/mozbuild/mozbuild/frontend/mach_commands.py
python/mozbuild/mozbuild/frontend/reader.py
python/mozbuild/mozbuild/frontend/sandbox.py
python/mozbuild/mozbuild/frontend/sandbox_symbols.py
python/mozbuild/mozbuild/test/common.py
python/mozbuild/mozbuild/test/frontend/__init__.py
python/mozbuild/mozbuild/test/frontend/data/include-basic/included.build
python/mozbuild/mozbuild/test/frontend/data/include-basic/moz.build
python/mozbuild/mozbuild/test/frontend/data/include-file-stack/included-1.build
python/mozbuild/mozbuild/test/frontend/data/include-file-stack/included-2.build
python/mozbuild/mozbuild/test/frontend/data/include-file-stack/moz.build
python/mozbuild/mozbuild/test/frontend/data/include-missing/moz.build
python/mozbuild/mozbuild/test/frontend/data/include-outside-topsrcdir/relative.build
python/mozbuild/mozbuild/test/frontend/data/include-relative-from-child/child/child.build
python/mozbuild/mozbuild/test/frontend/data/include-relative-from-child/child/child2.build
python/mozbuild/mozbuild/test/frontend/data/include-relative-from-child/child/grandchild/grandchild.build
python/mozbuild/mozbuild/test/frontend/data/include-relative-from-child/parent.build
python/mozbuild/mozbuild/test/frontend/data/include-topsrcdir-relative/moz.build
python/mozbuild/mozbuild/test/frontend/data/include-topsrcdir-relative/sibling.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-bad-dir/moz.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-basic/moz.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-included-from/child.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-included-from/moz.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-missing-include/moz.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-outside-topsrcdir/moz.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-read-unknown-global/moz.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-repeated-dir/moz.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-script-error/moz.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-syntax/moz.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-write-bad-value/moz.build
python/mozbuild/mozbuild/test/frontend/data/reader-error-write-unknown-global/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-all-vars/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-all-vars/parallel/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-all-vars/regular/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-all-vars/test/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-all-vars/test_tool/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-all-vars/tool/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-outside-topsrcdir/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-relative-dirs/bar/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-relative-dirs/foo/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-relative-dirs/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-repeated-dirs/bar/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-repeated-dirs/foo/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-repeated-dirs/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-simple/bar/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-simple/foo/biz/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-simple/foo/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-simple/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-tier-fails-in-subdir/foo/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-tier-fails-in-subdir/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-tier-simple/bar/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-tier-simple/baz/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-tier-simple/foo/biz/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-tier-simple/foo/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-tier-simple/foo_static/moz.build
python/mozbuild/mozbuild/test/frontend/data/traversal-tier-simple/moz.build
python/mozbuild/mozbuild/test/frontend/test_namespaces.py
python/mozbuild/mozbuild/test/frontend/test_reader.py
python/mozbuild/mozbuild/test/frontend/test_sandbox.py
python/mozbuild/mozbuild/test/frontend/test_sandbox_symbols.py
--- a/mach
+++ b/mach
@@ -37,16 +37,17 @@ SEARCH_PATHS = [
 ]
 
 # Individual files providing mach commands.
 MACH_MODULES = [
     'layout/tools/reftest/mach_commands.py',
     'python/mozboot/mozboot/mach_commands.py',
     'python/mozbuild/mozbuild/config.py',
     'python/mozbuild/mozbuild/mach_commands.py',
+    'python/mozbuild/mozbuild/frontend/mach_commands.py',
     'testing/mochitest/mach_commands.py',
     'testing/xpcshell/mach_commands.py',
 ]
 
 our_dir = os.path.dirname(os.path.abspath(__file__))
 
 try:
     import mach.main
--- a/python/Makefile.in
+++ b/python/Makefile.in
@@ -7,15 +7,16 @@ topsrcdir := @top_srcdir@
 srcdir := @srcdir@
 VPATH = @srcdir@
 
 include $(DEPTH)/config/autoconf.mk
 
 test_dirs := \
   mozbuild/mozbuild/test \
   mozbuild/mozbuild/test/compilation \
+  mozbuild/mozbuild/test/frontend \
   $(NULL)
 
 PYTHON_UNIT_TESTS := $(foreach dir,$(test_dirs),$(wildcard $(srcdir)/$(dir)/*.py))
 
 include $(topsrcdir)/config/rules.mk
 
 
--- a/python/mozbuild/README.rst
+++ b/python/mozbuild/README.rst
@@ -5,9 +5,35 @@ mozbuild
 mozbuild is a Python package providing functionality used by Mozilla's
 build system.
 
 Modules Overview
 ================
 
 * mozbuild.compilation -- Functionality related to compiling. This
   includes managing compiler warnings.
+* mozbuild.frontend -- Functionality for reading build frontend files
+  (what defines the build system) and converting them to data structures
+  which are fed into build backends to produce backend configurations.
 
+Overview
+========
+
+The build system consists of frontend files that define what to do. They
+say things like "compile X" "copy Y."
+
+The mozbuild.frontend package contains code for reading these frontend
+files and converting them to static data structures. The set of produced
+static data structures for the tree constitute the current build
+configuration.
+
+There exist entities called build backends. From a high level, build
+backends consume the build configuration and do something with it. They
+typically produce tool-specific files such as make files which can be used
+to build the tree.
+
+Builders are entities that build the tree. They typically have high
+cohesion with a specific build backend.
+
+Piecing it all together, we have frontend files that are parsed into data
+structures. These data structures are fed into a build backend. The output
+from build backends is used by builders to build the tree.
+
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/TODO
@@ -0,0 +1,3 @@
+dom/imptests Makefile.in's are autogenerated. See
+dom/imptests/writeMakefile.py and bug 782651. We will need to update
+writeMakefile.py to produce mozbuild files.
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/frontend/README.rst
@@ -0,0 +1,137 @@
+=================
+mozbuild.frontend
+=================
+
+The mozbuild.frontend package is of sufficient importance and complexity
+to warrant its own README file. If you are looking for documentation on
+how the build system gets started, you've come to the right place.
+
+Overview
+========
+
+The build system is defined by a bunch of files in the source tree called
+*mozbuild* files. Each *mozbuild* file defines a unique part of the overall
+build system. This includes information like "compile file X," "copy this
+file here," "link these files together to form a library." Together,
+all the *mozbuild* files define how the entire build system works.
+
+*mozbuild* files are actually Python scripts. However, their execution
+is governed by special rules. This will be explained later.
+
+Once a *mozbuild* file has executed, it is converted into a set of static
+data structures.
+
+The set of all data structures from all relevant *mozbuild* files
+constitutes the current build configuration.
+
+How *mozbuild* Files Work
+=========================
+
+As stated above, *mozbuild* files are actually Python scripts. However,
+their behavior is very different from what you would expect if you executed
+the file using the standard Python interpreter from the command line.
+
+There are two properties that make execution of *mozbuild* files special:
+
+1. They are evaluated in a sandbox which exposes a limited subset of Python
+2. There is a special set of global variables which hold the output from
+   execution.
+
+The limited subset of Python is actually an extremely limited subset.
+Only a few built-ins are exposed. These include *True*, *False*, and
+*None*. Global functions like *import*, *print*, and *open* aren't defined.
+Without these, *mozbuild* files can do very little. This is by design.
+
+The side-effects of the execution of a *mozbuild* file are used to define
+the build configuration. Specifically, variables set during the execution
+of a *mozbuild* file are examined and their values are used to populate
+data structures.
+
+The enforced convention is that all UPPERCASE names inside a sandbox are
+reserved and it is the value of these variables post-execution that is
+examined. Furthermore, the set of allowed UPPERCASE variable names and
+their types is statically defined. If you attempt to reference or assign
+to an UPPERCASE variable name that isn't known to the build system or
+attempt to assign a value of the wrong type (e.g. a string when it wants a
+list), an error will be raised during execution of the *mozbuild* file.
+This strictness is to ensure that assignment to all UPPERCASE variables
+actually does something. If things weren't this way, *mozbuild* files
+might think they were doing something but in reality wouldn't be. We don't
+want to create false promises, so we validate behavior strictly.
+
+If a variable is not UPPERCASE, you can do anything you want with it,
+provided it isn't a function or other built-in. In other words, normal
+Python rules apply.
+
+All of the logic for loading and evaluating *mozbuild* files is in the
+*reader* module. Of specific interest is the *MozbuildSandbox* class. The
+*BuildReader* class is also important, as it is in charge of
+instantiating *MozbuildSandbox* instances and traversing a tree of linked
+*mozbuild* files. Unless you are a core component of the build system,
+*BuildReader* is probably the only class you care about in this module.
+
+The set of variables and functions *exported* to the sandbox is defined by
+the *sandbox_symbols* module. These data structures are actually used to
+populate MozbuildSandbox instances. And, there are tests to ensure that the
+sandbox doesn't add new symbols without those symbols being added to the
+module. And, since the module contains documentation, this ensures the
+documentation is up to date (at least in terms of symbol membership).
+
+How Sandboxes are Converted into Data Structures
+================================================
+
+The output of a *mozbuild* file execution is essentially a dict of all
+the special UPPERCASE variables populated during its execution. While these
+dicts are data structures, they aren't the final data structures that
+represent the build configuration.
+
+We feed the *mozbuild* execution output (actually *reader.MozbuildSandbox*
+instances) into a *BuildDefinitionEmitter* class instance. This class is
+defined in the *emitter* module. *BuildDefinitionEmitter* converts the
+*MozbuildSandbox* instances into instances of the *BuildDefinition*-derived
+classes from the *data* module.
+
+All the classes in the *data* module define a domain-specific
+component of the build configuration. File compilation and IDL generation
+are separate classes, for example. The only thing these classes have in
+common is that they inherit from *BuildDefinition*, which is merely an
+abstract base class.
+
+The set of all emitted *BuildDefinition* instances (converted from executed
+*mozbuild* files) constitutes the aggregate build configuration. This is
+the authoritative definition of the build system and is what's used by
+all downstream consumers, such as backends. There is no monolithic build
+system configuration class. Instead, the build system configuration is
+modeled as a collection/iterable of *BuildDefinition*.
+
+There is no defined mapping between the number of
+*MozbuildSandbox*/*moz.build* instances and *BuildDefinition* instances.
+Some *mozbuild* files will emit only 1 *BuildDefinition* instance. Some
+will emit 7. Some may even emit 0!
+
+The purpose of this *emitter* layer between the raw *mozbuild* execution
+result and *BuildDefinition* is to facilitate additional normalization and
+verification of the output. The downstream consumer of the build
+configuration are build backends. And, there are several of these. There
+are common functions shared by backends related to examining the build
+configuration. It makes sense to move this functionality upstream as part
+of a shared pipe. Thus, *BuildDefinitionEmitter* exists.
+
+Other Notes
+===========
+
+*reader.BuildReader* and *emitter.BuildDefinitionEmitter* have a nice
+stream-based API courtesy of generators. When you hook them up properly,
+*BuildDefinition* instances can be consumed before all *mozbuild* files have
+been read. This means that errors down the pipe can trigger before all
+upstream tasks (such as executing and converting) are complete. This should
+reduce the turnaround time in the event of errors. This likely translates to
+a more rapid pace for implementing backends, which require lots of iterative
+runs through the entire system.
+
+In theory, the frontend to the build system is generic and could be used
+by any project. In practice, parts are specifically tailored towards
+Mozilla's needs. With a little work, the core build system bits could be
+separated into its own package, independent of the Mozilla bits. Or, one
+could simply replace the Mozilla-specific pieces in the *variables*, *data*,
+and *emitter* modules to reuse the core logic.
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/frontend/mach_commands.py
@@ -0,0 +1,171 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import print_function, unicode_literals
+
+import textwrap
+
+from mach.decorators import (
+    CommandArgument,
+    CommandProvider,
+    Command
+)
+
+from mozbuild.frontend.sandbox_symbols import (
+    FUNCTIONS,
+    SPECIAL_VARIABLES,
+    VARIABLES,
+    doc_to_paragraphs,
+)
+
+
+def get_doc(doc):
+    """Split documentation into summary line and everything else."""
+    paragraphs = doc_to_paragraphs(doc)
+
+    summary = paragraphs[0]
+    extra = paragraphs[1:]
+
+    return summary, extra
+
+def print_extra(extra):
+    """Prints the 'everything else' part of documentation intelligently."""
+    for para in extra:
+        for line in textwrap.wrap(para):
+            print(line)
+
+        print('')
+
+    if not len(extra):
+        print('')
+
+
+@CommandProvider
+class MozbuildFileCommands(object):
+    @Command('mozbuild-reference',
+        help='View reference documentation on mozbuild files.')
+    @CommandArgument('symbol', default=None, nargs='*',
+        help='Symbol to view help on. If not specified, all will be shown.')
+    @CommandArgument('--name-only', '-n', default=False, action='store_true',
+        help='Print symbol names only.')
+    def reference(self, symbol, name_only=False):
+        if name_only:
+            for s in sorted(VARIABLES.keys()):
+                print(s)
+
+            for s in sorted(FUNCTIONS.keys()):
+                print(s)
+
+            for s in sorted(SPECIAL_VARIABLES.keys()):
+                print(s)
+
+            return 0
+
+        if len(symbol):
+            for s in symbol:
+                if s in VARIABLES:
+                    self.variable_reference(s)
+                    continue
+                elif s in FUNCTIONS:
+                    self.function_reference(s)
+                    continue
+                elif s in SPECIAL_VARIABLES:
+                    self.special_reference(s)
+                    continue
+
+                print('Could not find symbol: %s' % s)
+                return 1
+
+            return 0
+
+        print('=========')
+        print('VARIABLES')
+        print('=========')
+        print('')
+        print('This section lists all the variables that may be set ')
+        print('in moz.build files.')
+        print('')
+
+        for v in sorted(VARIABLES.keys()):
+            self.variable_reference(v)
+
+        print('=========')
+        print('FUNCTIONS')
+        print('=========')
+        print('')
+        print('This section lists all the functions that may be called ')
+        print('in moz.build files.')
+        print('')
+
+        for f in sorted(FUNCTIONS.keys()):
+            self.function_reference(f)
+
+        print('=================')
+        print('SPECIAL VARIABLES')
+        print('=================')
+        print('')
+
+        for v in sorted(SPECIAL_VARIABLES.keys()):
+            self.special_reference(v)
+
+        return 0
+
+    def variable_reference(self, v):
+        typ, default, doc = VARIABLES[v]
+
+        print(v)
+        print('=' * len(v))
+        print('')
+
+        summary, extra = get_doc(doc)
+
+        print(summary)
+        print('')
+        print('Type: %s' % typ.__name__)
+        print('Default Value: %s' % default)
+        print('')
+        print_extra(extra)
+
+    def function_reference(self, f):
+        attr, args, doc = FUNCTIONS[f]
+
+        print(f)
+        print('=' * len(f))
+        print('')
+
+        summary, extra = get_doc(doc)
+
+        print(summary)
+        print('')
+
+        arg_types = []
+
+        for t in args:
+            if isinstance(t, list):
+                inner_types = [t2.__name__ for t2 in t]
+                arg_types.append(' | ' .join(inner_types))
+                continue
+
+            arg_types.append(t.__name__)
+
+        arg_s = '(%s)' % ', '.join(arg_types)
+
+        print('Arguments: %s' % arg_s)
+        print('')
+        print_extra(extra)
+
+    def special_reference(self, v):
+        typ, doc = SPECIAL_VARIABLES[v]
+
+        print(v)
+        print('=' * len(v))
+        print('')
+
+        summary, extra = get_doc(doc)
+
+        print(summary)
+        print('')
+        print('Type: %s' % typ.__name__)
+        print('')
+        print_extra(extra)
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/frontend/reader.py
@@ -0,0 +1,575 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# This file contains code for reading metadata from the build system into
+# data structures.
+
+r"""Read build frontend files into data structures.
+
+In terms of code architecture, the main interface is BuildReader. BuildReader
+starts with a root mozbuild file. It creates a new execution environment for
+this file, which is represented by the Sandbox class. The Sandbox class is what
+defines what is allowed to execute in an individual mozbuild file. The Sandbox
+consists of a local and global namespace, which are modeled by the
+LocalNamespace and GlobalNamespace classes, respectively. The global namespace
+contains all of the takeaway information from the execution. The local
+namespace is for throwaway local variables and its contents are discarded after
+execution.
+
+The BuildReader contains basic logic for traversing a tree of mozbuild files.
+It does this by examining specific variables populated during execution.
+"""
+
+from __future__ import print_function, unicode_literals
+
+import logging
+import os
+import sys
+import traceback
+import types
+
+from io import StringIO
+
+from mozbuild.util import (
+    ReadOnlyDefaultDict,
+    ReadOnlyDict,
+)
+
+from .sandbox import (
+    SandboxExecutionError,
+    SandboxLoadError,
+    Sandbox,
+)
+
+from .sandbox_symbols import (
+    FUNCTIONS,
+    VARIABLES,
+)
+
+
+if sys.version_info.major == 2:
+    text_type = unicode
+    type_type = types.TypeType
+else:
+    text_type = str
+    type_type = type
+
+def log(logger, level, action, params, formatter):
+    logger.log(level, formatter, extra={'action': action, 'params': params})
+
+
+class MozbuildSandbox(Sandbox):
+    """Implementation of a Sandbox tailored for mozbuild files.
+
+    We expose a few useful functions and expose the set of variables defining
+    Mozilla's build system.
+    """
+    def __init__(self, config, path):
+        """Create an empty mozbuild Sandbox.
+
+        config is a ConfigStatus instance (the output of configure). path is
+        the path of the main mozbuild file that is being executed. It is used
+        to compute encountered relative paths.
+        """
+        Sandbox.__init__(self, allowed_variables=VARIABLES)
+
+        self.config = config
+
+        topobjdir = os.path.abspath(config.topobjdir)
+
+        # This may not always hold true. If we ever have autogenerated mozbuild
+        # files in topobjdir, we'll need to change this.
+        assert os.path.normpath(path).startswith(os.path.normpath(config.topsrcdir))
+        assert not os.path.normpath(path).startswith(os.path.normpath(topobjdir))
+
+        relpath = os.path.relpath(path, config.topsrcdir).replace(os.sep, '/')
+        reldir = os.path.dirname(relpath)
+
+        with self._globals.allow_all_writes() as d:
+            d['TOPSRCDIR'] = config.topsrcdir
+            d['TOPOBJDIR'] = topobjdir
+            d['RELATIVEDIR'] = reldir
+            d['SRCDIR'] = os.path.join(config.topsrcdir, reldir).replace(os.sep, '/').rstrip('/')
+            d['OBJDIR'] = os.path.join(topobjdir, reldir).replace(os.sep, '/').rstrip('/')
+
+            d['CONFIG'] = ReadOnlyDefaultDict(config.substs,
+                global_default=None)
+
+            # Register functions.
+            for name, func in FUNCTIONS.items():
+                d[name] = getattr(self, func[0])
+
+        self._normalized_topsrcdir = os.path.normpath(config.topsrcdir)
+
+    def exec_file(self, path, filesystem_absolute=False):
+        """Override exec_file to normalize paths and restrict file loading.
+
+        If the path is absolute, behavior is governed by filesystem_absolute.
+        If filesystem_absolute is True, the path is interpreted as absolute on
+        the actual filesystem. If it is false, the path is treated as absolute
+        within the current topsrcdir.
+
+        If the path is not absolute, it will be treated as relative to the
+        currently executing file. If there is no currently executing file, it
+        will be treated as relative to topsrcdir.
+
+        Paths will be rejected if they do not fall under topsrcdir.
+        """
+        if os.path.isabs(path):
+            if not filesystem_absolute:
+                path = os.path.normpath(os.path.join(self.config.topsrcdir,
+                    path[1:]))
+
+        else:
+            if len(self._execution_stack):
+                path = os.path.normpath(os.path.join(
+                    os.path.dirname(self._execution_stack[-1]),
+                    path))
+            else:
+                path = os.path.normpath(os.path.join(
+                    self.config.topsrcdir, path))
+
+        # realpath() is needed for true security. But, this isn't for security
+        # protection, so it is omitted.
+        normalized_path = os.path.normpath(path)
+        if not normalized_path.startswith(self._normalized_topsrcdir):
+            raise SandboxLoadError(list(self._execution_stack),
+                sys.exc_info()[2], illegal_path=path)
+
+        Sandbox.exec_file(self, path)
+
+    def _add_tier_directory(self, tier, reldir, static=False):
+        """Register a tier directory with the build."""
+        if isinstance(reldir, text_type):
+            reldir = [reldir]
+
+        if not tier in self['TIERS']:
+            self['TIERS'][tier] = {
+                'regular': [],
+                'static': [],
+            }
+
+        key = 'static' if static else 'regular'
+
+        for path in reldir:
+            if path in self['TIERS'][tier][key]:
+                raise Exception('Directory has already been registered with '
+                    'tier: %s' % path)
+
+            self['TIERS'][tier][key].append(path)
+
+    def _include(self, path):
+        """Include and exec another file within the context of this one."""
+
+        # exec_file() handles normalization and verification of the path.
+        self.exec_file(path)
+
+
+class SandboxValidationError(Exception):
+    """Represents an error encountered when validating sandbox results."""
+    pass
+
+
+class BuildReaderError(Exception):
+    """Represents errors encountered during BuildReader execution.
+
+    The main purpose of this class is to facilitate user-actionable error
+    messages. Execution errors should say:
+
+      - Why they failed
+      - Where they failed
+      - What can be done to prevent the error
+
+    A lot of the code in this class should arguably be inside sandbox.py.
+    However, extraction is somewhat difficult given the additions
+    MozbuildSandbox has over Sandbox (e.g. the concept of included files -
+    which affect error messages, of course).
+    """
+    def __init__(self, file_stack, trace, sandbox_exec_error=None,
+        sandbox_load_error=None, validation_error=None, other_error=None):
+
+        self.file_stack = file_stack
+        self.trace = trace
+        self.sandbox_exec = sandbox_exec_error
+        self.sandbox_load = sandbox_load_error
+        self.validation_error = validation_error
+        self.other = other_error
+
+    @property
+    def main_file(self):
+        return self.file_stack[-1]
+
+    @property
+    def actual_file(self):
+        # We report the file that called out to the file that couldn't load.
+        if self.sandbox_load is not None:
+            if len(self.sandbox_load.file_stack) > 1:
+                return self.sandbox_load.file_stack[-2]
+
+            if len(self.file_stack) > 1:
+                return self.file_stack[-2]
+
+        if self.sandbox_error is not None and \
+            len(self.sandbox_error.file_stack):
+            return self.sandbox_error.file_stack[-1]
+
+        return self.file_stack[-1]
+
+    @property
+    def sandbox_error(self):
+        return self.sandbox_exec or self.sandbox_load
+
+    def __str__(self):
+        s = StringIO()
+
+        delim = '=' * 30
+        s.write('%s\nERROR PROCESSING MOZBUILD FILE\n%s\n\n' % (delim, delim))
+
+        s.write('The error occurred while processing the following file:\n')
+        s.write('\n')
+        s.write('    %s\n' % self.actual_file)
+        s.write('\n')
+
+        if self.actual_file != self.main_file and not self.sandbox_load:
+            s.write('This file was included as part of processing:\n')
+            s.write('\n')
+            s.write('    %s\n' % self.main_file)
+            s.write('\n')
+
+        if self.sandbox_error is not None:
+            self._print_sandbox_error(s)
+        elif self.validation_error is not None:
+            s.write('The error occurred when validating the result of ')
+            s.write('the execution. The reported error is:\n')
+            s.write('\n')
+            s.write('    %s\n' % self.validation_error.message)
+            s.write('\n')
+        else:
+            s.write('The error appears to be part of the %s ' % __name__)
+            s.write('Python module itself! It is possible you have stumbled ')
+            s.write('across a legitimate bug.\n')
+            s.write('\n')
+
+            for l in traceback.format_exception(type(self.other), self.other,
+                self.trace):
+                s.write(unicode(l))
+
+        return s.getvalue()
+
+    def _print_sandbox_error(self, s):
+        # Try to find the frame of the executed code.
+        script_frame = None
+        for frame in traceback.extract_tb(self.sandbox_error.trace):
+            if frame[0] == self.actual_file:
+                script_frame = frame
+
+            # Reset if we enter a new execution context. This prevents errors
+            # in this module from being attributes to a script.
+            elif frame[0] == __file__ and frame[2] == 'exec_source':
+                script_frame = None
+
+        if script_frame is not None:
+            s.write('The error was triggered on line %d ' % script_frame[1])
+            s.write('of this file:\n')
+            s.write('\n')
+            s.write('    %s\n' % script_frame[3])
+            s.write('\n')
+
+        if self.sandbox_load is not None:
+            self._print_sandbox_load_error(s)
+            return
+
+        self._print_sandbox_exec_error(s)
+
+    def _print_sandbox_load_error(self, s):
+        assert self.sandbox_load is not None
+
+        if self.sandbox_load.illegal_path is not None:
+            s.write('The underlying problem is an illegal file access. ')
+            s.write('This is likely due to trying to access a file ')
+            s.write('outside of the top source directory.\n')
+            s.write('\n')
+            s.write('The path whose access was denied is:\n')
+            s.write('\n')
+            s.write('    %s\n' % self.sandbox_load.illegal_path)
+            s.write('\n')
+            s.write('Modify the script to not access this file and ')
+            s.write('try again.\n')
+            return
+
+        if self.sandbox_load.read_error is not None:
+            if not os.path.exists(self.sandbox_load.read_error):
+                s.write('The underlying problem is we referenced a path ')
+                s.write('that does not exist. That path is:\n')
+                s.write('\n')
+                s.write('    %s\n' % self.sandbox_load.read_error)
+                s.write('\n')
+                s.write('Either create the file if it needs to exist or ')
+                s.write('do not reference it.\n')
+            else:
+                s.write('The underlying problem is a referenced path could ')
+                s.write('not be read. The trouble path is:\n')
+                s.write('\n')
+                s.write('    %s\n' % self.sandbox_load.read_error)
+                s.write('\n')
+                s.write('It is possible the path is not correct. Is it ')
+                s.write('pointing to a directory? It could also be a file ')
+                s.write('permissions issue. Ensure that the file is ')
+                s.write('readable.\n')
+
+            return
+
+        # This module is buggy if you see this.
+        raise AssertionError('SandboxLoadError with unhandled properties!')
+
+    def _print_sandbox_exec_error(self, s):
+        assert self.sandbox_exec is not None
+
+        inner = self.sandbox_exec.exc_value
+
+        if isinstance(inner, SyntaxError):
+            s.write('The underlying problem is a Python syntax error ')
+            s.write('on line %d:\n' % inner.lineno)
+            s.write('\n')
+            s.write('    %s\n' % inner.text)
+            s.write((' ' * (inner.offset + 4)) + '^\n')
+            s.write('\n')
+            s.write('Fix the syntax error and try again.\n')
+            return
+
+        if isinstance(inner, KeyError):
+            self._print_keyerror(inner, s)
+        elif isinstance(inner, ValueError):
+            self._print_valueerror(inner, s)
+        else:
+            self._print_exception(inner, s)
+
+    def _print_keyerror(self, inner, s):
+        if inner.args[0] not in ('global_ns', 'local_ns'):
+            self._print_exception(unner, s)
+            return
+
+        if inner.args[0] == 'global_ns':
+            verb = None
+            if inner.args[1] == 'get_unknown':
+                verb = 'read'
+            elif inner.args[1] == 'set_unknown':
+                verb = 'write'
+            else:
+                raise AssertionError('Unhandled global_ns: %s' % inner.args[1])
+
+            s.write('The underlying problem is an attempt to %s ' % verb)
+            s.write('a reserved UPPERCASE variable that does not exist.\n')
+            s.write('\n')
+            s.write('The variable %s causing the error is:\n' % verb)
+            s.write('\n')
+            s.write('    %s\n' % inner.args[2])
+            s.write('\n')
+            s.write('Please change the file to not use this variable.\n')
+            s.write('\n')
+            s.write('For reference, the set of valid variables is:\n')
+            s.write('\n')
+            s.write(', '.join(sorted(VARIABLES.keys())) + '\n')
+            return
+
+        s.write('The underlying problem is a reference to an undefined ')
+        s.write('local variable:\n')
+        s.write('\n')
+        s.write('    %s\n' % inner.args[2])
+        s.write('\n')
+        s.write('Please change the file to not reference undefined ')
+        s.write('variables and try again.\n')
+
+    def _print_valueerror(self, inner, s):
+        if inner.args[0] not in ('global_ns', 'local_ns'):
+            self._print_exception(inner, s)
+            return
+
+        assert inner.args[1] == 'set_type'
+
+        s.write('The underlying problem is an attempt to write an illegal ')
+        s.write('value to a special variable.\n')
+        s.write('\n')
+        s.write('The variable whose value was rejected is:\n')
+        s.write('\n')
+        s.write('    %s' % inner.args[2])
+        s.write('\n')
+        s.write('The value being written to it was of the following type:\n')
+        s.write('\n')
+        s.write('    %s\n' % type(inner.args[3]).__name__)
+        s.write('\n')
+        s.write('This variable expects the following type(s):\n')
+        s.write('\n')
+        if type(inner.args[4]) == type_type:
+            s.write('    %s\n' % inner.args[4].__name__)
+        else:
+            for t in inner.args[4]:
+                s.write( '    %s\n' % t.__name__)
+        s.write('\n')
+        s.write('Change the file to write a value of the appropriate type ')
+        s.write('and try again.\n')
+
+    def _print_exception(self, e, s):
+        s.write('An error was encountered as part of executing the file ')
+        s.write('itself. The error appears to be the fault of the script.\n')
+        s.write('\n')
+        s.write('The error as reported by Python is:\n')
+        s.write('\n')
+        s.write('    %s\n' % traceback.format_exception_only(type(e), e))
+
+
+class BuildReader(object):
+    """Read a tree of mozbuild files into data structures.
+
+    This is where the build system starts. You give it a tree configuration
+    (the output of configuration) and it executes the moz.build files and
+    collects the data they define.
+    """
+
+    def __init__(self, config):
+        self.config = config
+        self.topsrcdir = config.topsrcdir
+
+        self._log = logging.getLogger(__name__)
+        self._read_files = set()
+        self._normalized_topsrcdir = os.path.normpath(config.topsrcdir)
+        self._execution_stack = []
+
+    def read_topsrcdir(self):
+        """Read the tree of mozconfig files into a data structure.
+
+        This starts with the tree's top-most mozbuild file and descends into
+        all linked mozbuild files until all relevant files have been evaluated.
+
+        This is a generator of Sandbox instances. As each mozbuild file is
+        read, a new Sandbox is created. Each created Sandbox is returned.
+        """
+        path = os.path.join(self.topsrcdir, 'moz.build')
+        return self.read_mozbuild(path, read_tiers=True,
+            filesystem_absolute=True)
+
+    def read_mozbuild(self, path, read_tiers=False, filesystem_absolute=False,
+            descend=True):
+        """Read and process a mozbuild file, descending into children.
+
+        This starts with a single mozbuild file, executes it, and descends into
+        other referenced files per our traversal logic.
+
+        The traversal logic is to iterate over the *DIRS variables, treating
+        each element as a relative directory path. For each encountered
+        directory, we will open the moz.build file located in that
+        directory in a new Sandbox and process it.
+
+        If read_tiers is True (it should only be True for the top-level
+        mozbuild file in a project), the TIERS variable will be used for
+        traversal as well.
+
+        If descend is True (the default), we will descend into child
+        directories and files per variable values.
+
+        Traversal is performed depth first (for no particular reason).
+        """
+        self._execution_stack.append(path)
+        try:
+            for s in self._read_mozbuild(path, read_tiers=read_tiers,
+                filesystem_absolute=filesystem_absolute, descend=descend):
+                yield s
+
+        except BuildReaderError as bre:
+            raise bre
+
+        except SandboxExecutionError as se:
+            raise BuildReaderError(list(self._execution_stack),
+                sys.exc_info()[2], sandbox_exec_error=se)
+
+        except SandboxLoadError as sle:
+            raise BuildReaderError(list(self._execution_stack),
+                sys.exc_info()[2], sandbox_load_error=sle)
+
+        except SandboxValidationError as ve:
+            raise BuildReaderError(list(self._execution_stack),
+                sys.exc_info()[2], validation_error=ve)
+
+        except Exception as e:
+            raise BuildReaderError(list(self._execution_stack),
+                sys.exc_info()[2], other_error=e)
+
+    def _read_mozbuild(self, path, read_tiers, filesystem_absolute, descend):
+        path = os.path.normpath(path)
+        log(self._log, logging.DEBUG, 'read_mozbuild', {'path': path},
+            'Reading file: {path}')
+
+        if path in self._read_files:
+            log(self._log, logging.WARNING, 'read_already', {'path': path},
+                'File already read. Skipping: {path}')
+            return
+
+        self._read_files.add(path)
+
+        sandbox = MozbuildSandbox(self.config, path)
+        sandbox.exec_file(path, filesystem_absolute=filesystem_absolute)
+        yield sandbox
+
+        # Traverse into referenced files.
+
+        # We first collect directories populated in variables.
+        dir_vars = ['DIRS', 'PARALLEL_DIRS', 'TOOL_DIRS']
+
+        if self.config.substs.get('ENABLE_TESTS', False) == '1':
+            dir_vars.extend(['TEST_DIRS', 'TEST_TOOL_DIRS'])
+
+        # It's very tempting to use a set here. Unfortunately, the recursive
+        # make backend needs order preserved. Once we autogenerate all backend
+        # files, we should be able to convert this to a set.
+        dirs = []
+        for var in dir_vars:
+            if not var in sandbox:
+                continue
+
+            for d in sandbox[var]:
+                if d in dirs:
+                    raise SandboxValidationError(
+                        'Directory (%s) registered multiple times in %s' % (
+                            d, var))
+
+                dirs.append(d)
+
+        # We also have tiers whose members are directories.
+        if 'TIERS' in sandbox:
+            if not read_tiers:
+                raise SandboxValidationError(
+                    'TIERS defined but it should not be')
+
+            for tier, values in sandbox['TIERS'].items():
+                for var in ('regular', 'static'):
+                    for d in values[var]:
+                        if d in dirs:
+                            raise SandboxValidationError(
+                                'Tier directory (%s) registered multiple '
+                                'times in %s' % (d, tier))
+                        dirs.append(d)
+
+        curdir = os.path.dirname(path)
+        for relpath in dirs:
+            child_path = os.path.join(curdir, relpath, 'moz.build')
+
+            # Ensure we don't break out of the topsrcdir. We don't do realpath
+            # because it isn't necessary. If there are symlinks in the srcdir,
+            # that's not our problem. We're not a hosted application: we don't
+            # need to worry about security too much.
+            child_path = os.path.normpath(child_path)
+            if not child_path.startswith(self._normalized_topsrcdir):
+                raise SandboxValidationError(
+                    'Attempting to process file outside of topsrcdir: %s' %
+                        child_path)
+
+            if not descend:
+                continue
+
+            for res in self.read_mozbuild(child_path, read_tiers=False,
+                filesystem_absolute=True):
+                yield res
+
+        self._execution_stack.pop()
+
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/frontend/sandbox.py
@@ -0,0 +1,348 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+r"""Python sandbox implementation for build files.
+
+This module contains classes for Python sandboxes that execute in a
+highly-controlled environment.
+
+The main class is `Sandbox`. This provides an execution environment for Python
+code.
+
+The behavior inside sandboxes is mostly regulated by the `GlobalNamespace` and
+`LocalNamespace` classes. These represent the global and local namespaces in
+the sandbox, respectively.
+
+Code in this module takes a different approach to exception handling compared
+to what you'd see elsewhere in Python. Arguments to built-in exceptions like
+KeyError are machine parseable. This machine-friendly data is used to present
+user-friendly error messages in the case of errors.
+"""
+
+from __future__ import unicode_literals
+
+import copy
+import os
+import sys
+
+from contextlib import contextmanager
+
+from mozbuild.util import (
+    ReadOnlyDefaultDict,
+    ReadOnlyDict,
+)
+
+
+class GlobalNamespace(dict):
+    """Represents the globals namespace in a sandbox.
+
+    This is a highly specialized dictionary employing light magic.
+
+    At the crux we have the concept of a restricted keys set. Only very
+    specific keys may be retrieved or mutated. The rules are as follows:
+
+        - The '__builtins__' key is hardcoded and is read-only.
+        - The set of variables that can be assigned or accessed during
+          execution is passed into the constructor.
+
+    When variables are assigned to, we verify assignment is allowed. Assignment
+    is allowed if the variable is known (set defined at constructor time) and
+    if the value being assigned is the expected type (also defined at
+    constructor time).
+
+    When variables are read, we first try to read the existing value. If a
+    value is not found and it is defined in the allowed variables set, we
+    return the default value for it. We don't assign default values until
+    they are accessed because this makes debugging the end-result much
+    simpler. Instead of a data structure with lots of empty/default values,
+    you have a data structure with only the values that were read or touched.
+
+    Instantiators of this class are given a backdoor to perform setting of
+    arbitrary values. e.g.
+
+        ns = GlobalNamespace()
+        with ns.allow_all_writes():
+            ns['foo'] = True
+
+        ns['bar'] = True  # KeyError raised.
+    """
+
+    # The default set of builtins.
+    BUILTINS = ReadOnlyDict({
+        # Only real Python built-ins should go here.
+        'None': None,
+        'False': False,
+        'True': True,
+    })
+
+    def __init__(self, allowed_variables=None, builtins=None):
+        """Create a new global namespace having specific variables.
+
+        allowed_variables is a dict of the variables that can be queried and
+        mutated. Keys in this dict are the strings representing keys in this
+        namespace which are valid. Values are tuples of type, default value,
+        and a docstring describing the purpose of the variable.
+
+        builtins is the value to use for the special __builtins__ key. If not
+        defined, the BUILTINS constant attached to this class is used. The
+        __builtins__ object is read-only.
+        """
+        builtins = builtins or self.BUILTINS
+
+        assert isinstance(builtins, ReadOnlyDict)
+
+        dict.__init__(self, {'__builtins__': builtins})
+
+        self._allowed_variables = allowed_variables or {}
+
+        # We need to record this because it gets swallowed as part of
+        # evaluation.
+        self.last_name_error = None
+
+        self._allow_all_writes = False
+
+    def __getitem__(self, name):
+        try:
+            return dict.__getitem__(self, name)
+        except KeyError:
+            pass
+
+        # The variable isn't present yet. Fall back to VARIABLES.
+        default = self._allowed_variables.get(name, None)
+        if default is None:
+            self.last_name_error = KeyError('global_ns', 'get_unknown', name)
+            raise self.last_name_error
+
+        dict.__setitem__(self, name, copy.deepcopy(default[1]))
+        return dict.__getitem__(self, name)
+
+    def __setitem__(self, name, value):
+        if self._allow_all_writes:
+            dict.__setitem__(self, name, value)
+            return
+
+        # We don't need to check for name.isupper() here because LocalNamespace
+        # only sends variables our way if isupper() is True.
+        default = self._allowed_variables.get(name, None)
+
+        if default is None:
+            self.last_name_error = KeyError('global_ns', 'set_unknown', name,
+                value)
+            raise self.last_name_error
+
+        if not isinstance(value, default[0]):
+            self.last_name_error = ValueError('global_ns', 'set_type', name,
+                value, default[0])
+            raise self.last_name_error
+
+        dict.__setitem__(self, name, value)
+
+    @contextmanager
+    def allow_all_writes(self):
+        """Allow any variable to be written to this instance.
+
+        This is used as a context manager. When activated, all writes
+        (__setitem__ calls) are allowed. When the context manager is exited,
+        the instance goes back to its default behavior of only allowing
+        whitelisted mutations.
+        """
+        self._allow_all_writes = True
+        yield self
+        self._allow_all_writes = False
+
+
+class LocalNamespace(dict):
+    """Represents the locals namespace in a Sandbox.
+
+    This behaves like a dict except with some additional behavior tailored
+    to our sandbox execution model.
+
+    Under normal rules of exec(), doing things like += could have interesting
+    consequences. Keep in mind that a += is really a read, followed by the
+    creation of a new variable, followed by a write. If the read came from the
+    global namespace, then the write would go to the local namespace, resulting
+    in fragmentation. This is not desired.
+
+    LocalNamespace proxies reads and writes for global-looking variables
+    (read: UPPERCASE) to the global namespace. This means that attempting to
+    read or write an unknown variable results in exceptions raised from the
+    GlobalNamespace.
+    """
+    def __init__(self, global_ns):
+        """Create a local namespace associated with a GlobalNamespace."""
+        dict.__init__({})
+
+        self._globals = global_ns
+        self.last_name_error = None
+
+    def __getitem__(self, name):
+        if name.isupper():
+            return self._globals[name]
+
+        return dict.__getitem__(self, name)
+
+    def __setitem__(self, name, value):
+        if name.isupper():
+            self._globals[name] = value
+            return
+
+        dict.__setitem__(self, name, value)
+
+
+class SandboxError(Exception):
+    def __init__(self, file_stack):
+        self.file_stack = file_stack
+
+
+class SandboxExecutionError(SandboxError):
+    """Represents errors encountered during execution of a Sandbox.
+
+    This is a simple container exception. It's purpose is to capture state
+    so something else can report on it.
+    """
+    def __init__(self, file_stack, exc_type, exc_value, trace):
+        SandboxError.__init__(self, file_stack)
+
+        self.exc_type = exc_type
+        self.exc_value = exc_value
+        self.trace = trace
+
+
+class SandboxLoadError(SandboxError):
+    """Represents errors encountered when loading a file for execution.
+
+    This exception represents errors in a Sandbox that occurred as part of
+    loading a file. The error could have occurred in the course of executing
+    a file. If so, the file_stack will be non-empty and the file that caused
+    the load will be on top of the stack.
+    """
+    def __init__(self, file_stack, trace, illegal_path=None, read_error=None):
+        SandboxError.__init__(self, file_stack)
+
+        self.trace = trace
+        self.illegal_path = illegal_path
+        self.read_error = read_error
+
+
+class Sandbox(object):
+    """Represents a sandbox for executing Python code.
+
+    This class both provides a sandbox for execution of a single mozbuild
+    frontend file as well as an interface to the results of that execution.
+
+    Sandbox is effectively a glorified wrapper around compile() + exec(). You
+    point it at some Python code and it executes it. The main difference from
+    executing Python code like normal is that the executed code is very limited
+    in what it can do: the sandbox only exposes a very limited set of Python
+    functionality. Only specific types and functions are available. This
+    prevents executed code from doing things like import modules, open files,
+    etc.
+
+    Sandboxes are bound to a mozconfig instance. These objects are produced by
+    the output of configure.
+
+    Sandbox instances can be accessed like dictionaries to facilitate result
+    retrieval. e.g. foo = sandbox['FOO']. Direct assignment is not allowed.
+
+    Each sandbox has associated with it a GlobalNamespace and LocalNamespace.
+    Only data stored in the GlobalNamespace is retrievable via the dict
+    interface. This is because the local namespace should be irrelevant: it
+    should only contain throwaway variables.
+    """
+    def __init__(self, allowed_variables=None, builtins=None):
+        """Initialize a Sandbox ready for execution.
+
+        The arguments are proxied to GlobalNamespace.__init__.
+        """
+        self._globals = GlobalNamespace(allowed_variables=allowed_variables,
+            builtins=builtins)
+        self._locals = LocalNamespace(self._globals)
+        self._execution_stack = []
+
+    def exec_file(self, path):
+        """Execute code at a path in the sandbox.
+
+        The path must be absolute.
+        """
+        assert os.path.isabs(path)
+
+        source = None
+
+        try:
+            with open(path, 'rt') as fd:
+                source = fd.read()
+        except Exception as e:
+            raise SandboxLoadError(list(self._execution_stack),
+                sys.exc_info()[2], read_error=path)
+
+        self.exec_source(source, path)
+
+    def exec_source(self, source, path):
+        """Execute Python code within a string.
+
+        The passed string should contain Python code to be executed. The string
+        will be compiled and executed.
+
+        You should almost always go through exec_file() because exec_source()
+        does not perform extra path normalization. This can cause relative
+        paths to behave weirdly.
+        """
+        self._execution_stack.append(path)
+
+        # We don't have to worry about bytecode generation here because we are
+        # too low-level for that. However, we could add bytecode generation via
+        # the marshall module if parsing performance were ever an issue.
+
+        try:
+            # compile() inherits the __future__ from the module by default. We
+            # do want Unicode literals.
+            code = compile(source, path, 'exec')
+            exec(code, self._globals, self._locals)
+        except SandboxError as e:
+            raise e
+        except NameError as e:
+            # A NameError is raised when a local or global could not be found.
+            # The original KeyError has been dropped by the interpreter.
+            # However, we should have it cached in our namespace instances!
+
+            # Unless a script is doing something wonky like catching NameError
+            # itself (that would be silly), if there is an exception on the
+            # global namespace, that's our error.
+            actual = e
+
+            if self._globals.last_name_error is not None:
+                actual = self._globals.last_name_error
+            elif self._locals.last_name_error is not None:
+                actual = self._locals.last_name_error
+
+            raise SandboxExecutionError(list(self._execution_stack),
+                type(actual), actual, sys.exc_info()[2])
+
+        except Exception as e:
+            # Need to copy the stack otherwise we get a reference and that is
+            # mutated during the finally.
+            exc = sys.exc_info()
+            raise SandboxExecutionError(list(self._execution_stack), exc[0],
+                exc[1], exc[2])
+        finally:
+            self._execution_stack.pop()
+
+    # Dict interface proxies reads to global namespace.
+    def __len__(self):
+        return len(self._globals)
+
+    def __getitem__(self, name):
+        return self._globals[name]
+
+    def __iter__(self):
+        return iter(self._globals)
+
+    def iterkeys(self):
+        return self.__iter__()
+
+    def __contains__(self, key):
+        return key in self._globals
+
+    def get(self, key, default=None):
+        return self._globals.get(key, default)
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/frontend/sandbox_symbols.py
@@ -0,0 +1,249 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+######################################################################
+# DO NOT UPDATE THIS FILE WITHOUT SIGN-OFF FROM A BUILD MODULE PEER. #
+######################################################################
+
+r"""Defines the global config variables.
+
+This module contains data structures defining the global symbols that have
+special meaning in the frontend files for the build system.
+
+If you are looking for the absolute authority on what the global namespace in
+the Sandbox consists of, you've come to the right place.
+"""
+
+from __future__ import unicode_literals
+
+from collections import OrderedDict
+
+
+def doc_to_paragraphs(doc):
+    """Take a documentation string and converts it to paragraphs.
+
+    This normalizes the inline strings in VARIABLES and elsewhere in this file.
+
+    It returns a list of paragraphs. It is up to the caller to insert newlines
+    or to wrap long lines (e.g. by using textwrap.wrap()).
+    """
+    lines = [line.strip() for line in doc.split('\n')]
+
+    paragraphs = []
+    current = []
+    for line in lines:
+        if not len(line):
+            if len(current):
+                paragraphs.append(' '.join(current))
+                current = []
+
+            continue
+
+        current.append(line)
+
+    if len(current):
+        paragraphs.append(' '.join(current))
+
+    return paragraphs
+
+
+# This defines the set of mutable global variables.
+#
+# Each variable is a tuple of:
+#
+#   (type, default_value, docs)
+#
+VARIABLES = {
+    # Variables controlling reading of other frontend files.
+    'DIRS': (list, [],
+        """Child directories to descend into looking for build frontend files.
+
+        This works similarly to the DIRS variable in make files. Each str value
+        in the list is the name of a child directory. When this file is done
+        parsing, the build reader will descend into each listed directory and
+        read the frontend file there. If there is no frontend file, an error
+        is raised.
+
+        Values are relative paths. They can be multiple directory levels
+        above or below. Use ".." for parent directories and "/" for path
+        delimiters.
+        """),
+
+    'PARALLEL_DIRS': (list, [],
+        """A parallel version of DIRS.
+
+        Ideally this variable does not exist. It is provided so a transition
+        from recursive makefiles can be made. Once the build system has been
+        converted to not use Makefile's for the build frontend, this will
+        likely go away.
+        """),
+
+    'TOOL_DIRS': (list, [],
+        """Like DIRS but for tools.
+
+        Tools are for pieces of the build system that aren't required to
+        produce a working binary (in theory). They provide things like test
+        code and utilities.
+        """),
+
+    'TEST_DIRS': (list, [],
+        """Like DIRS but only for directories that contain test-only code.
+
+        If tests are not enabled, this variable will be ignored.
+
+        This variable may go away once the transition away from Makefiles is
+        complete.
+        """),
+
+    'TEST_TOOL_DIRS': (list, [],
+        """TOOL_DIRS that is only executed if tests are enabled.
+        """),
+
+
+    'TIERS': (OrderedDict, OrderedDict(),
+        """Defines directories constituting the tier traversal mechanism.
+
+        The recursive make backend iteration is organized into tiers. There are
+        major tiers (keys in this dict) that correspond roughly to applications
+        or libraries being built. e.g. base, nspr, js, platform, app. Within
+        each tier are phases like export, libs, and tools. The recursive make
+        backend iterates over each phase in the first tier then proceeds to the
+        next tier until all tiers are exhausted.
+
+        Tiers are a way of working around deficiencies in recursive make. These
+        will probably disappear once we no longer rely on recursive make for
+        the build backend. They will likely be replaced by DIRS.
+
+        This variable is typically not populated directly. Instead, it is
+        populated by calling add_tier_dir().
+        """),
+}
+
+# The set of functions exposed to the sandbox.
+#
+# Each entry is a tuple of:
+#
+#  (method attribute, (argument types), docs)
+#
+# The first element is an attribute on Sandbox that should be a function type.
+#
+FUNCTIONS = {
+    'include': ('_include', (str,),
+        """Include another mozbuild file in the context of this one.
+
+        This is similar to a #include in C languages. The filename passed to
+        the function will be read and its contents will be evaluated within the
+        context of the calling file.
+
+        If a relative path is given, it is evaluated as relative to the file
+        currently being processed. If there is a chain of multiple include(),
+        the relative path computation is from the most recent/active file.
+
+        If an absolute path is given, it is evaluated from TOPSRCDIR. In other
+        words, include('/foo') references the path TOPSRCDIR + '/foo'.
+
+        Example usage
+        -------------
+
+        # Include "sibling.build" from the current directory.
+        include('sibling.build')
+
+        # Include "foo.build" from a path within the top source directory.
+        include('/elsewhere/foo.build')
+        """),
+
+    'add_tier_dir': ('_add_tier_directory', (str, [str, list], bool),
+        """Register a directory for tier traversal.
+
+        This is the preferred way to populate the TIERS variable.
+
+        Tiers are how the build system is organized. The build process is
+        divided into major phases called tiers. The most important tiers are
+        "platform" and "apps." The platform tier builds the Gecko platform
+        (typically outputting libxul). The apps tier builds the configured
+        application (browser, mobile/android, b2g, etc).
+
+        This function is typically only called by the main moz.build file or a
+        file directly included by the main moz.build file. An error will be
+        raised if it is called when it shouldn't be.
+
+        An error will also occur if you attempt to add the same directory to
+        the same tier multiple times.
+
+        Example usage
+        -------------
+
+        # Register a single directory with the 'platform' tier.
+        add_tier_dir('platform', 'xul')
+
+        # Register multiple directories with the 'app' tier.
+        add_tier_dir('app', ['components', 'base'])
+
+        # Register a directory as having static content (no dependencies).
+        add_tier_dir('base', 'foo', static=True)
+        """),
+
+}
+
+# Special variables. These complement VARIABLES.
+SPECIAL_VARIABLES = {
+    'TOPSRCDIR': (str,
+        """Constant defining the top source directory.
+
+        The top source directory is the parent directory containing the source
+        code and all build files. It is typically the root directory of a
+        cloned repository.
+        """),
+
+    'TOPOBJDIR': (str,
+        """Constant defining the top object directory.
+
+        The top object directory is the parent directory which will contain
+        the output of the build. This is commonly referred to as "the object
+        directory."
+        """),
+
+    'RELATIVEDIR': (str,
+        """Constant defining the relative path of this file.
+
+        The relative path is from TOPSRCDIR. This is defined as relative to the
+        main file being executed, regardless of whether additional files have
+        been included using include().
+        """),
+
+    'SRCDIR': (str,
+        """Constant defining the source directory of this file.
+
+        This is the path inside TOPSRCDIR where this file is located. It is the
+        same as TOPSRCDIR + RELATIVEDIR.
+        """),
+
+    'OBJDIR': (str,
+        """The path to the object directory for this file.
+
+        Is is the same as TOPOBJDIR + RELATIVEDIR.
+        """),
+
+    'CONFIG': (dict,
+        """Dictionary containing the current configuration variables.
+
+        All the variables defined by the configuration system are available
+        through this object. e.g. ENABLE_TESTS, CFLAGS, etc.
+
+        Values in this container are read-only. Attempts at changing values
+        will result in a run-time error.
+
+        Access to an unknown variable will return None.
+        """),
+
+    '__builtins__': (dict,
+        """Exposes Python built-in types.
+
+        The set of exposed Python built-ins is currently:
+
+            True
+            False
+            None
+        """),
+}
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/common.py
@@ -0,0 +1,31 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import unicode_literals
+
+import os
+
+from mach.logging import LoggingManager
+
+
+# By including this module, tests get structured logging.
+log_manager = LoggingManager()
+log_manager.add_terminal_logging()
+
+# mozconfig is not a reusable type (it's actually a module) so, we
+# have to mock it.
+class MockConfig(object):
+    def __init__(self, topsrcdir='/path/to/topsrcdir'):
+        self.topsrcdir = topsrcdir
+        self.topobjdir = '/path/to/topobjdir'
+
+        self.substs = {
+            'MOZ_FOO': 'foo',
+            'MOZ_BAR': 'bar',
+            'MOZ_TRUE': '1',
+            'MOZ_FALSE': '',
+        }
+
+    def child_path(self, p):
+        return os.path.join(self.topsrcdir, p)
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-basic/included.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS += ['bar']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-basic/moz.build
@@ -0,0 +1,6 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['foo']
+
+include('included.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-file-stack/included-1.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('included-2.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-file-stack/included-2.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+ILLEGAL = True
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-file-stack/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('included-1.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-missing/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('missing.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-outside-topsrcdir/relative.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('../moz.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-relative-from-child/child/child.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('../parent.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-relative-from-child/child/child2.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('grandchild/grandchild.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-relative-from-child/child/grandchild/grandchild.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('../../parent.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-relative-from-child/parent.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['foo']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-topsrcdir-relative/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('/sibling.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/include-topsrcdir-relative/sibling.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['foo']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-bad-dir/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['foo']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-basic/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+ILLEGAL = True
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-included-from/child.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+ILLEGAL = True
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-included-from/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('child.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-missing-include/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('missing.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-outside-topsrcdir/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+include('../include-basic/moz.build')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-read-unknown-global/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+l = FOO
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-repeated-dir/moz.build
@@ -0,0 +1,6 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['foo']
+
+DIRS += ['foo']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-script-error/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+foo = True + None
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-syntax/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+foo =
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-write-bad-value/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = 'dir'
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/reader-error-write-unknown-global/moz.build
@@ -0,0 +1,6 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['dir1', 'dir2']
+
+FOO = 'bar'
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-all-vars/moz.build
@@ -0,0 +1,8 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS += ['regular']
+PARALLEL_DIRS = ['parallel']
+TEST_DIRS = ['test']
+TEST_TOOL_DIRS = ['test_tool']
+TOOL_DIRS = ['tool']
new file mode 100644
new file mode 100644
new file mode 100644
new file mode 100644
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-outside-topsrcdir/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['../../foo']
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-relative-dirs/foo/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['../bar']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-relative-dirs/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['foo']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-repeated-dirs/bar/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['../foo']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-repeated-dirs/foo/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['../bar']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-repeated-dirs/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['foo', 'bar']
new file mode 100644
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-simple/foo/moz.build
@@ -0,0 +1,1 @@
+DIRS = ['biz']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-simple/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['foo', 'bar']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-tier-fails-in-subdir/foo/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+add_tier_dir('illegal', 'IRRELEVANT')
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-tier-fails-in-subdir/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['foo']
new file mode 100644
new file mode 100644
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-tier-simple/foo/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+DIRS = ['biz']
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/traversal-tier-simple/moz.build
@@ -0,0 +1,8 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+add_tier_dir('t1', 'foo')
+add_tier_dir('t1', 'foo_static', static=True)
+
+add_tier_dir('t2', 'bar')
+add_tier_dir('t3', 'baz', static=True)
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/test_namespaces.py
@@ -0,0 +1,132 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import unicode_literals
+
+import unittest
+
+from mozunit import main
+
+from mozbuild.frontend.sandbox import (
+    GlobalNamespace,
+    LocalNamespace,
+)
+
+from mozbuild.frontend.sandbox_symbols import VARIABLES
+
+
+class TestGlobalNamespace(unittest.TestCase):
+    def test_builtins(self):
+        ns = GlobalNamespace()
+
+        self.assertIn('__builtins__', ns)
+        self.assertEqual(ns['__builtins__']['True'], True)
+
+    def test_key_rejection(self):
+        # Lowercase keys should be rejected during normal operation.
+        ns = GlobalNamespace(allowed_variables=VARIABLES)
+
+        with self.assertRaises(KeyError) as ke:
+            ns['foo'] = True
+
+        e = ke.exception.args
+        self.assertEqual(e[0], 'global_ns')
+        self.assertEqual(e[1], 'set_unknown')
+        self.assertEqual(e[2], 'foo')
+        self.assertTrue(e[3])
+
+        # Unknown uppercase keys should be rejected.
+        with self.assertRaises(KeyError) as ke:
+            ns['FOO'] = True
+
+        e = ke.exception.args
+        self.assertEqual(e[0], 'global_ns')
+        self.assertEqual(e[1], 'set_unknown')
+        self.assertEqual(e[2], 'FOO')
+        self.assertTrue(e[3])
+
+    def test_allowed_set(self):
+        self.assertIn('DIRS', VARIABLES)
+
+        ns = GlobalNamespace(allowed_variables=VARIABLES)
+
+        ns['DIRS'] = ['foo']
+        self.assertEqual(ns['DIRS'], ['foo'])
+
+    def test_value_checking(self):
+        ns = GlobalNamespace(allowed_variables=VARIABLES)
+
+        # Setting to a non-allowed type should not work.
+        with self.assertRaises(ValueError) as ve:
+            ns['DIRS'] = True
+
+        e = ve.exception.args
+        self.assertEqual(e[0], 'global_ns')
+        self.assertEqual(e[1], 'set_type')
+        self.assertEqual(e[2], 'DIRS')
+        self.assertTrue(e[3])
+        self.assertEqual(e[4], list)
+
+    def test_allow_all_writes(self):
+        ns = GlobalNamespace(allowed_variables=VARIABLES)
+
+        with ns.allow_all_writes() as d:
+            d['foo'] = True
+            self.assertTrue(d['foo'])
+
+        with self.assertRaises(KeyError) as ke:
+            ns['foo'] = False
+
+        self.assertEqual(ke.exception.args[1], 'set_unknown')
+
+        self.assertTrue(d['foo'])
+
+    def test_key_checking(self):
+        # Checking for existence of a key should not populate the key if it
+        # doesn't exist.
+        g = GlobalNamespace(allowed_variables=VARIABLES)
+
+        self.assertFalse('DIRS' in g)
+        self.assertFalse('DIRS' in g)
+
+
+class TestLocalNamespace(unittest.TestCase):
+    def test_locals(self):
+        g = GlobalNamespace(allowed_variables=VARIABLES)
+        l = LocalNamespace(g)
+
+        l['foo'] = ['foo']
+        self.assertEqual(l['foo'], ['foo'])
+
+        l['foo'] += ['bar']
+        self.assertEqual(l['foo'], ['foo', 'bar'])
+
+    def test_global_proxy_reads(self):
+        g = GlobalNamespace(allowed_variables=VARIABLES)
+        g['DIRS'] = ['foo']
+
+        l = LocalNamespace(g)
+
+        self.assertEqual(l['DIRS'], g['DIRS'])
+
+        # Reads to missing UPPERCASE vars should result in KeyError.
+        with self.assertRaises(KeyError) as ke:
+            v = l['FOO']
+
+        e = ke.exception
+        self.assertEqual(e.args[0], 'global_ns')
+        self.assertEqual(e.args[1], 'get_unknown')
+
+    def test_global_proxy_writes(self):
+        g = GlobalNamespace(allowed_variables=VARIABLES)
+        l = LocalNamespace(g)
+
+        l['DIRS'] = ['foo']
+
+        self.assertEqual(l['DIRS'], ['foo'])
+        self.assertEqual(g['DIRS'], ['foo'])
+
+
+if __name__ == '__main__':
+    main()
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/test_reader.py
@@ -0,0 +1,232 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import unicode_literals
+
+import os
+import sys
+import unittest
+
+from mozunit import main
+
+from mozbuild.frontend.reader import BuildReaderError
+from mozbuild.frontend.reader import BuildReader
+
+from mozbuild.test.common import MockConfig
+
+
+if sys.version_info.major == 2:
+    text_type = 'unicode'
+else:
+    text_type = 'str'
+
+data_path = os.path.abspath(os.path.dirname(__file__))
+data_path = os.path.join(data_path, 'data')
+
+
+class TestBuildReader(unittest.TestCase):
+    def config(self, name):
+        path = os.path.join(data_path, name)
+
+        return MockConfig(path)
+
+    def reader(self, name, enable_tests=False):
+        config = self.config(name)
+
+        if enable_tests:
+            config.substs['ENABLE_TESTS'] = '1'
+
+        return BuildReader(config)
+
+    def file_path(self, name, *args):
+        return os.path.join(data_path, name, *args)
+
+    def test_dirs_traversal_simple(self):
+        reader = self.reader('traversal-simple')
+
+        sandboxes = list(reader.read_topsrcdir())
+
+        self.assertEqual(len(sandboxes), 4)
+
+    def test_dirs_traversal_no_descend(self):
+        reader = self.reader('traversal-simple')
+
+        path = os.path.join(reader.topsrcdir, 'moz.build')
+        self.assertTrue(os.path.exists(path))
+
+        sandboxes = list(reader.read_mozbuild(path,
+            filesystem_absolute=True, descend=False))
+
+        self.assertEqual(len(sandboxes), 1)
+
+    def test_dirs_traversal_all_variables(self):
+        reader = self.reader('traversal-all-vars', enable_tests=True)
+
+        sandboxes = list(reader.read_topsrcdir())
+        self.assertEqual(len(sandboxes), 6)
+
+    def test_tiers_traversal(self):
+        reader = self.reader('traversal-tier-simple')
+
+        sandboxes = list(reader.read_topsrcdir())
+        self.assertEqual(len(sandboxes), 6)
+
+    def test_tier_subdir(self):
+        # add_tier_dir() should fail when not in the top directory.
+        reader = self.reader('traversal-tier-fails-in-subdir')
+
+        with self.assertRaises(Exception):
+            list(reader.read_topsrcdir())
+
+    def test_relative_dirs(self):
+        # Ensure relative directories are traversed.
+        reader = self.reader('traversal-relative-dirs')
+
+        sandboxes = list(reader.read_topsrcdir())
+        self.assertEqual(len(sandboxes), 3)
+
+    def test_repeated_dirs_ignored(self):
+        # Ensure repeated directories are ignored.
+        reader = self.reader('traversal-repeated-dirs')
+
+        sandboxes = list(reader.read_topsrcdir())
+        self.assertEqual(len(sandboxes), 3)
+
+    def test_outside_topsrcdir(self):
+        # References to directories outside the topsrcdir should fail.
+        reader = self.reader('traversal-outside-topsrcdir')
+
+        with self.assertRaises(Exception):
+            list(reader.read_topsrcdir())
+
+    def test_error_basic(self):
+        reader = self.reader('reader-error-basic')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertEqual(e.actual_file, self.file_path('reader-error-basic',
+            'moz.build'))
+
+        self.assertIn('The error occurred while processing the', str(e))
+
+    def test_error_included_from(self):
+        reader = self.reader('reader-error-included-from')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertEqual(e.actual_file,
+            self.file_path('reader-error-included-from', 'child.build'))
+        self.assertEqual(e.main_file,
+            self.file_path('reader-error-included-from', 'moz.build'))
+
+        self.assertIn('This file was included as part of processing', str(e))
+
+    def test_error_syntax_error(self):
+        reader = self.reader('reader-error-syntax')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertIn('Python syntax error on line 4', str(e))
+        self.assertIn('    foo =', str(e))
+        self.assertIn('         ^', str(e))
+
+    def test_error_read_unknown_global(self):
+        reader = self.reader('reader-error-read-unknown-global')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertIn('The error was triggered on line 4', str(e))
+        self.assertIn('The underlying problem is an attempt to read', str(e))
+        self.assertIn('    FOO', str(e))
+
+    def test_error_write_unknown_global(self):
+        reader = self.reader('reader-error-write-unknown-global')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertIn('The error was triggered on line 6', str(e))
+        self.assertIn('The underlying problem is an attempt to write', str(e))
+        self.assertIn('    FOO', str(e))
+
+    def test_error_write_bad_value(self):
+        reader = self.reader('reader-error-write-bad-value')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertIn('The error was triggered on line 4', str(e))
+        self.assertIn('is an attempt to write an illegal value to a special',
+            str(e))
+
+        self.assertIn('variable whose value was rejected is:\n\n    DIRS',
+            str(e))
+
+        self.assertIn('written to it was of the following type:\n\n    %s' % text_type,
+            str(e))
+
+        self.assertIn('expects the following type(s):\n\n    list', str(e))
+
+    def test_error_illegal_path(self):
+        reader = self.reader('reader-error-outside-topsrcdir')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertIn('The underlying problem is an illegal file access',
+            str(e))
+
+    def test_error_missing_include_path(self):
+        reader = self.reader('reader-error-missing-include')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertIn('we referenced a path that does not exist', str(e))
+
+    def test_error_script_error(self):
+        reader = self.reader('reader-error-script-error')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertIn('The error appears to be the fault of the script',
+            str(e))
+        self.assertIn('    ["TypeError: unsupported operand', str(e))
+
+    def test_error_bad_dir(self):
+        reader = self.reader('reader-error-bad-dir')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertIn('we referenced a path that does not exist', str(e))
+
+    def test_error_repeated_dir(self):
+        reader = self.reader('reader-error-repeated-dir')
+
+        with self.assertRaises(BuildReaderError) as bre:
+            list(reader.read_topsrcdir())
+
+        e = bre.exception
+        self.assertIn('Directory (foo) registered multiple times in DIRS',
+            str(e))
+
+
+if __name__ == '__main__':
+    main()
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/test_sandbox.py
@@ -0,0 +1,272 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import unicode_literals
+
+import os
+import shutil
+import unittest
+
+from mozunit import main
+
+from mozbuild.frontend.reader import MozbuildSandbox
+
+from mozbuild.frontend.sandbox import (
+    SandboxExecutionError,
+    SandboxLoadError,
+)
+
+from mozbuild.frontend.sandbox_symbols import (
+    FUNCTIONS,
+    SPECIAL_VARIABLES,
+    VARIABLES,
+)
+
+from mozbuild.test.common import MockConfig
+
+
+test_data_path = os.path.abspath(os.path.dirname(__file__))
+test_data_path = os.path.join(test_data_path, 'data')
+
+
+class TestSandbox(unittest.TestCase):
+    def sandbox(self, relpath='moz.build', data_path=None):
+        config = None
+
+        if data_path is not None:
+            config = MockConfig(os.path.join(test_data_path, data_path))
+        else:
+            config = MockConfig()
+
+        return MozbuildSandbox(config, config.child_path(relpath))
+
+    def test_default_state(self):
+        sandbox = self.sandbox()
+        config = sandbox.config
+
+        self.assertEqual(sandbox['TOPSRCDIR'], config.topsrcdir)
+        self.assertEqual(sandbox['TOPOBJDIR'],
+            os.path.abspath(config.topobjdir))
+        self.assertEqual(sandbox['RELATIVEDIR'], '')
+        self.assertEqual(sandbox['SRCDIR'], config.topsrcdir)
+        self.assertEqual(sandbox['OBJDIR'],
+            os.path.abspath(config.topobjdir).replace(os.sep, '/'))
+
+    def test_symbol_presence(self):
+        # Ensure no discrepancies between the master symbol table and what's in
+        # the sandbox.
+        sandbox = self.sandbox()
+
+        all_symbols = set()
+        all_symbols |= set(FUNCTIONS.keys())
+        all_symbols |= set(SPECIAL_VARIABLES.keys())
+
+        for symbol in sandbox:
+            self.assertIn(symbol, all_symbols)
+            all_symbols.remove(symbol)
+
+        self.assertEqual(len(all_symbols), 0)
+
+    def test_path_calculation(self):
+        sandbox = self.sandbox('foo/bar/moz.build')
+        config = sandbox.config
+
+        self.assertEqual(sandbox['RELATIVEDIR'], 'foo/bar')
+        self.assertEqual(sandbox['SRCDIR'], '/'.join([config.topsrcdir,
+            'foo/bar']))
+        self.assertEqual(sandbox['OBJDIR'],
+            os.path.abspath('/'.join([config.topobjdir, 'foo/bar'])).replace(os.sep, '/'))
+
+    def test_config_access(self):
+        sandbox = self.sandbox()
+        config = sandbox.config
+
+        self.assertIn('CONFIG', sandbox)
+        self.assertEqual(sandbox['CONFIG']['MOZ_TRUE'], '1')
+        self.assertEqual(sandbox['CONFIG']['MOZ_FOO'], config.substs['MOZ_FOO'])
+
+        # Access to an undefined substitution should return None.
+        self.assertNotIn('MISSING', sandbox['CONFIG'])
+        self.assertIsNone(sandbox['CONFIG']['MISSING'])
+
+        # Should shouldn't be allowed to assign to the config.
+        with self.assertRaises(Exception):
+            sandbox['CONFIG']['FOO'] = ''
+
+    def test_dict_interface(self):
+        sandbox = self.sandbox()
+        config = sandbox.config
+
+        self.assertFalse('foo' in sandbox)
+        self.assertFalse('FOO' in sandbox)
+
+        self.assertTrue(sandbox.get('foo', True))
+        self.assertEqual(sandbox.get('TOPSRCDIR'), config.topsrcdir)
+        self.assertGreater(len(sandbox), 6)
+
+        for key in sandbox:
+            continue
+
+        for key in sandbox.iterkeys():
+            continue
+
+    def test_exec_source_success(self):
+        sandbox = self.sandbox()
+
+        sandbox.exec_source('foo = True', 'foo.py')
+
+        self.assertNotIn('foo', sandbox)
+
+    def test_exec_compile_error(self):
+        sandbox = self.sandbox()
+
+        with self.assertRaises(SandboxExecutionError) as se:
+            sandbox.exec_source('2f23;k;asfj', 'foo.py')
+
+        self.assertEqual(se.exception.file_stack, ['foo.py'])
+        self.assertIsInstance(se.exception.exc_value, SyntaxError)
+
+    def test_exec_import_denied(self):
+        sandbox = self.sandbox()
+
+        with self.assertRaises(SandboxExecutionError) as se:
+            sandbox.exec_source('import sys', 'import.py')
+
+        self.assertIsInstance(se.exception, SandboxExecutionError)
+        self.assertEqual(se.exception.exc_type, ImportError)
+
+    def test_exec_source_multiple(self):
+        sandbox = self.sandbox()
+
+        sandbox.exec_source('DIRS = ["foo"]', 'foo.py')
+        sandbox.exec_source('DIRS = ["bar"]', 'foo.py')
+
+        self.assertEqual(sandbox['DIRS'], ['bar'])
+
+    def test_exec_source_illegal_key_set(self):
+        sandbox = self.sandbox()
+
+        with self.assertRaises(SandboxExecutionError) as se:
+            sandbox.exec_source('ILLEGAL = True', 'foo.py')
+
+        e = se.exception
+        self.assertIsInstance(e.exc_value, KeyError)
+
+        e = se.exception.exc_value
+        self.assertEqual(e.args[0], 'global_ns')
+        self.assertEqual(e.args[1], 'set_unknown')
+
+    def test_add_tier_dir_regular_str(self):
+        sandbox = self.sandbox()
+
+        sandbox.exec_source('add_tier_dir("t1", "foo")', 'foo.py')
+
+        self.assertEqual(sandbox['TIERS']['t1'],
+            {'regular': ['foo'], 'static': []})
+
+    def test_add_tier_dir_regular_list(self):
+        sandbox = self.sandbox()
+
+        sandbox.exec_source('add_tier_dir("t1", ["foo", "bar"])', 'foo.py')
+
+        self.assertEqual(sandbox['TIERS']['t1'],
+            {'regular': ['foo', 'bar'], 'static': []})
+
+    def test_add_tier_dir_static(self):
+        sandbox = self.sandbox()
+
+        sandbox.exec_source('add_tier_dir("t1", "foo", static=True)', 'foo.py')
+
+        self.assertEqual(sandbox['TIERS']['t1'],
+            {'regular': [], 'static': ['foo']})
+
+    def test_tier_order(self):
+        sandbox = self.sandbox()
+
+        source = '''
+add_tier_dir('t1', 'foo')
+add_tier_dir('t1', 'bar')
+add_tier_dir('t2', 'baz', static=True)
+add_tier_dir('t3', 'biz')
+add_tier_dir('t1', 'bat', static=True)
+'''
+
+        sandbox.exec_source(source, 'foo.py')
+
+        self.assertEqual([k for k in sandbox['TIERS'].keys()], ['t1', 't2', 't3'])
+
+    def test_tier_multiple_registration(self):
+        sandbox = self.sandbox()
+
+        sandbox.exec_source('add_tier_dir("t1", "foo")', 'foo.py')
+
+        with self.assertRaises(SandboxExecutionError):
+            sandbox.exec_source('add_tier_dir("t1", "foo")', 'foo.py')
+
+    def test_include_basic(self):
+        sandbox = self.sandbox(data_path='include-basic')
+
+        sandbox.exec_file('moz.build')
+
+        self.assertEqual(sandbox['DIRS'], ['foo', 'bar'])
+
+    def test_include_outside_topsrcdir(self):
+        sandbox = self.sandbox(data_path='include-outside-topsrcdir')
+
+        with self.assertRaises(SandboxLoadError) as se:
+            sandbox.exec_file('relative.build')
+
+        expected = os.path.join(test_data_path, 'moz.build')
+        self.assertEqual(se.exception.illegal_path, expected)
+
+    def test_include_error_stack(self):
+        # Ensure the path stack is reported properly in exceptions.
+        sandbox = self.sandbox(data_path='include-file-stack')
+
+        with self.assertRaises(SandboxExecutionError) as se:
+            sandbox.exec_file('moz.build')
+
+        e = se.exception
+        self.assertIsInstance(e.exc_value, KeyError)
+
+        args = e.exc_value.args
+        self.assertEqual(args[0], 'global_ns')
+        self.assertEqual(args[1], 'set_unknown')
+        self.assertEqual(args[2], 'ILLEGAL')
+
+        expected_stack = [os.path.join(sandbox.config.topsrcdir, p) for p in [
+            'moz.build', 'included-1.build', 'included-2.build']]
+
+        self.assertEqual(e.file_stack, expected_stack)
+
+    def test_include_missing(self):
+        sandbox = self.sandbox(data_path='include-missing')
+
+        with self.assertRaises(SandboxLoadError) as sle:
+            sandbox.exec_file('moz.build')
+
+        self.assertIsNotNone(sle.exception.read_error)
+
+    def test_include_relative_from_child_dir(self):
+        # A relative path from a subdirectory should be relative from that
+        # child directory.
+        sandbox = self.sandbox(data_path='include-relative-from-child')
+        sandbox.exec_file('child/child.build')
+        self.assertEqual(sandbox['DIRS'], ['foo'])
+
+        sandbox = self.sandbox(data_path='include-relative-from-child')
+        sandbox.exec_file('child/child2.build')
+        self.assertEqual(sandbox['DIRS'], ['foo'])
+
+    def test_include_topsrcdir_relative(self):
+        # An absolute path for include() is relative to topsrcdir.
+
+        sandbox = self.sandbox(data_path='include-topsrcdir-relative')
+        sandbox.exec_file('moz.build')
+
+        self.assertEqual(sandbox['DIRS'], ['foo'])
+
+
+if __name__ == '__main__':
+    main()
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/test_sandbox_symbols.py
@@ -0,0 +1,50 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import unittest
+
+from mozunit import main
+
+from mozbuild.frontend.sandbox_symbols import (
+    FUNCTIONS,
+    SPECIAL_VARIABLES,
+    VARIABLES,
+)
+
+
+class TestSymbols(unittest.TestCase):
+    def _verify_doc(self, doc):
+        # Documentation should be of the format:
+        # """SUMMARY LINE
+        #
+        # EXTRA PARAGRAPHS
+        # """
+
+        self.assertNotIn('\r', doc)
+
+        lines = doc.split('\n')
+
+        # No trailing whitespace.
+        for line in lines[0:-1]:
+            self.assertEqual(line, line.rstrip())
+
+        self.assertGreater(len(lines), 0)
+        self.assertGreater(len(lines[0].strip()), 0)
+
+        # Last line should be empty.
+        self.assertEqual(lines[-1].strip(), '')
+
+    def test_documentation_formatting(self):
+        for typ, default, doc in VARIABLES.values():
+            self._verify_doc(doc)
+
+        for attr, args, doc in FUNCTIONS.values():
+            self._verify_doc(doc)
+
+        for typ, doc in SPECIAL_VARIABLES.values():
+            self._verify_doc(doc)
+
+
+if __name__ == '__main__':
+    main()