Commit 1f6b48ae by Chad Miller Committed by Timothée Peignier

Make compilers run directly, not inside shells

Names on disk and config files should not have to be safe for shells to
interpret. As close to possible, we should take literals and give literals to
the OS kernel to operate on directly. For filename globs, it is our
responsiblility to expand them, and if we had no problem with backwards
compatibility, we would insist config files' SCRIPT_ARGUMENTS parameters are
tuples of discrete values. Delegating those to a shell breaks clear boundaries
of interpreetation and will always be prone to errors, oversight, and
incompatibility.

So, now, we never take names that are unsafe and try to make then safe for a
shell, because we don't need a shell.

This fixes #444, which had problems with Windows paths being insensible to the
crazy quoting we hoped would make a filename safe for a shell.

This fixes #494, which had a compiler-attempt stdout captured as part of a
string interpreted by a shell. When the compiler didn't exist, that shell
expression STILL created empty files, and the pipeline thenafter served an
empty file as if it were real compiler output.
parent e9ecc735
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
import subprocess
try: from tempfile import NamedTemporaryFile
from shlex import quote
except ImportError:
from pipes import quote
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.encoding import smart_bytes from django.utils.encoding import smart_bytes
from django.utils.six import string_types
from pipeline.conf import settings from pipeline.conf import settings
from pipeline.exceptions import CompilerError from pipeline.exceptions import CompilerError
...@@ -40,8 +38,8 @@ class Compiler(object): ...@@ -40,8 +38,8 @@ class Compiler(object):
infile = finders.find(input_path) infile = finders.find(input_path)
outfile = self.output_path(infile, compiler.output_extension) outfile = self.output_path(infile, compiler.output_extension)
outdated = compiler.is_outdated(input_path, output_path) outdated = compiler.is_outdated(input_path, output_path)
compiler.compile_file(quote(infile), quote(outfile), compiler.compile_file(infile, outfile,
outdated=outdated, force=force) outdated=outdated, force=force)
return output_path return output_path
else: else:
return input_path return input_path
...@@ -90,18 +88,55 @@ class CompilerBase(object): ...@@ -90,18 +88,55 @@ class CompilerBase(object):
class SubProcessCompiler(CompilerBase): class SubProcessCompiler(CompilerBase):
def execute_command(self, command, content=None, cwd=None): def execute_command(self, command, cwd=None, stdout_captured=None):
import subprocess """Execute a command at cwd, saving its normal output at
pipe = subprocess.Popen(command, shell=True, cwd=cwd, stdout_captured. Errors, defined as nonzero return code or a failure
stdout=subprocess.PIPE, stdin=subprocess.PIPE, to start execution, will raise a CompilerError exception with a
stderr=subprocess.PIPE) description of the cause. They do not write output.
if content:
content = smart_bytes(content) This is file-system safe (any valid file names are allowed, even with
stdout, stderr = pipe.communicate(content) spaces or crazy characters) and OS agnostic (existing and future OSes
if stderr.strip(): that Python supports should already work).
raise CompilerError(stderr)
if self.verbose: The only thing weird here is that any incoming command arg item may
print(stderr) itself be a tuple. This allows compiler implementations to look clean
if pipe.returncode != 0: while supporting historical string config settings and maintaining
raise CompilerError("Command '{0}' returned non-zero exit status {1}".format(command, pipe.returncode)) backwards compatibility. Thus, we flatten one layer deep.
return stdout ((env, foocomp), infile, (-arg,)) -> (env, foocomp, infile, -arg)
"""
argument_list = []
for flattening_arg in command:
if isinstance(flattening_arg, string_types):
argument_list.append(flattening_arg)
else:
argument_list.extend(flattening_arg)
try:
# We always catch stdout in a file, but we may not have a use for it.
temp_file_container = cwd or os.path.dirname(stdout_captured or "") or os.getcwd()
with NamedTemporaryFile(delete=False, dir=temp_file_container) as stdout:
compiling = subprocess.Popen(argument_list, cwd=cwd,
stdout=stdout,
stderr=subprocess.PIPE)
_, stderr = compiling.communicate()
if compiling.returncode != 0:
stdout_captured = None # Don't save erroneous result.
raise CompilerError(
"{0!r} exit code {1}\n{2}".format(argument_list, compiling.returncode, stderr))
# User wants to see everything that happened.
if self.verbose:
with open(stdout.name) as out:
print(out.read())
print(stderr)
except OSError as e:
stdout_captured = None # Don't save erroneous result.
raise CompilerError(e)
finally:
# Decide what to do with captured stdout.
if stdout_captured:
os.rename(stdout.name, os.path.join(cwd or os.curdir, stdout_captured))
else:
os.remove(stdout.name)
...@@ -13,10 +13,10 @@ class CoffeeScriptCompiler(SubProcessCompiler): ...@@ -13,10 +13,10 @@ class CoffeeScriptCompiler(SubProcessCompiler):
def compile_file(self, infile, outfile, outdated=False, force=False): def compile_file(self, infile, outfile, outdated=False, force=False):
if not outdated and not force: if not outdated and not force:
return # File doesn't need to be recompiled return # File doesn't need to be recompiled
command = "%s -cp %s %s > %s" % ( command = (
settings.COFFEE_SCRIPT_BINARY, settings.COFFEE_SCRIPT_BINARY,
"-cp",
settings.COFFEE_SCRIPT_ARGUMENTS, settings.COFFEE_SCRIPT_ARGUMENTS,
infile, infile,
outfile
) )
return self.execute_command(command) return self.execute_command(command, stdout_captured=outfile)
...@@ -13,10 +13,11 @@ class ES6Compiler(SubProcessCompiler): ...@@ -13,10 +13,11 @@ class ES6Compiler(SubProcessCompiler):
def compile_file(self, infile, outfile, outdated=False, force=False): def compile_file(self, infile, outfile, outdated=False, force=False):
if not outdated and not force: if not outdated and not force:
return # File doesn't need to be recompiled return # File doesn't need to be recompiled
command = "%s %s %s -o %s" % ( command = (
settings.BABEL_BINARY, settings.BABEL_BINARY,
settings.BABEL_ARGUMENTS, settings.BABEL_ARGUMENTS,
infile, infile,
"-o",
outfile outfile
) )
return self.execute_command(command) return self.execute_command(command)
...@@ -14,10 +14,9 @@ class LessCompiler(SubProcessCompiler): ...@@ -14,10 +14,9 @@ class LessCompiler(SubProcessCompiler):
def compile_file(self, infile, outfile, outdated=False, force=False): def compile_file(self, infile, outfile, outdated=False, force=False):
# Pipe to file rather than provide outfile arg due to a bug in lessc # Pipe to file rather than provide outfile arg due to a bug in lessc
command = "%s %s %s > %s" % ( command = (
settings.LESS_BINARY, settings.LESS_BINARY,
settings.LESS_ARGUMENTS, settings.LESS_ARGUMENTS,
infile, infile,
outfile
) )
return self.execute_command(command, cwd=dirname(infile)) return self.execute_command(command, cwd=dirname(infile), stdout_captured=outfile)
...@@ -13,10 +13,10 @@ class LiveScriptCompiler(SubProcessCompiler): ...@@ -13,10 +13,10 @@ class LiveScriptCompiler(SubProcessCompiler):
def compile_file(self, infile, outfile, outdated=False, force=False): def compile_file(self, infile, outfile, outdated=False, force=False):
if not outdated and not force: if not outdated and not force:
return # File doesn't need to be recompiled return # File doesn't need to be recompiled
command = "%s -cp %s %s > %s" % ( command = (
settings.LIVE_SCRIPT_BINARY, settings.LIVE_SCRIPT_BINARY,
"-cp",
settings.LIVE_SCRIPT_ARGUMENTS, settings.LIVE_SCRIPT_ARGUMENTS,
infile, infile,
outfile
) )
return self.execute_command(command) return self.execute_command(command, stdout_captured=outfile)
...@@ -13,7 +13,7 @@ class SASSCompiler(SubProcessCompiler): ...@@ -13,7 +13,7 @@ class SASSCompiler(SubProcessCompiler):
return filename.endswith(('.scss', '.sass')) return filename.endswith(('.scss', '.sass'))
def compile_file(self, infile, outfile, outdated=False, force=False): def compile_file(self, infile, outfile, outdated=False, force=False):
command = "%s %s %s %s" % ( command = (
settings.SASS_BINARY, settings.SASS_BINARY,
settings.SASS_ARGUMENTS, settings.SASS_ARGUMENTS,
infile, infile,
......
...@@ -13,7 +13,7 @@ class StylusCompiler(SubProcessCompiler): ...@@ -13,7 +13,7 @@ class StylusCompiler(SubProcessCompiler):
return filename.endswith('.styl') return filename.endswith('.styl')
def compile_file(self, infile, outfile, outdated=False, force=False): def compile_file(self, infile, outfile, outdated=False, force=False):
command = "%s %s %s" % ( command = (
settings.STYLUS_BINARY, settings.STYLUS_BINARY,
settings.STYLUS_ARGUMENTS, settings.STYLUS_ARGUMENTS,
infile infile
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import shlex
from django.conf import settings as _settings from django.conf import settings as _settings
DEFAULTS = { DEFAULTS = {
...@@ -77,12 +79,26 @@ DEFAULTS = { ...@@ -77,12 +79,26 @@ DEFAULTS = {
class PipelineSettings(object): class PipelineSettings(object):
''' """
Container object for pipeline settings Container object for pipeline settings
''' """
def __init__(self, wrapped_settings): def __init__(self, wrapped_settings):
DEFAULTS.update(wrapped_settings) DEFAULTS.update(wrapped_settings)
self.__dict__ = DEFAULTS self.__dict__ = DEFAULTS
def __getattr__(self, name):
if hasattr(self, name):
value = getattr(self, name)
elif name in self:
value = DEFAULTS[name]
else:
raise AttributeError("'%s' setting not found" % name)
if name.endswith(("_BINARY", "_ARGUMENTS")):
if isinstance(value, (type(u""), type(b""))):
return tuple(shlex.split(value))
return tuple(value)
return value
settings = PipelineSettings(_settings.PIPELINE) settings = PipelineSettings(_settings.PIPELINE)
from __future__ import unicode_literals from __future__ import unicode_literals
import os
from django.test import TestCase from django.test import TestCase
from pipeline.conf import settings from pipeline.conf import settings
from pipeline.compilers import Compiler, CompilerBase from pipeline.compilers import Compiler, CompilerBase, SubProcessCompiler
from pipeline.collector import default_collector from pipeline.collector import default_collector
from pipeline.exceptions import CompilerError
from tests.utils import _ from tests.utils import _
class FailingCompiler(SubProcessCompiler):
output_extension = 'junk'
def match_file(self, path):
return path.endswith('.coffee')
def compile_file(self, infile, outfile, outdated=False, force=False):
command = (("/usr/bin/env", "false",),)
return self.execute_command(command)
class InvalidCompiler(SubProcessCompiler):
output_extension = 'junk'
def match_file(self, path):
return path.endswith('.coffee')
def compile_file(self, infile, outfile, outdated=False, force=False):
command = (
("this-exists-nowhere-as-a-command-and-should-fail",),
infile,
outfile
)
return self.execute_command(command)
class CopyingCompiler(SubProcessCompiler):
output_extension = 'junk'
def match_file(self, path):
return path.endswith('.coffee')
def compile_file(self, infile, outfile, outdated=False, force=False):
command = (
("cp",),
("--no-dereference", "--preserve=links",),
infile,
outfile
)
return self.execute_command(command)
class LineNumberingCompiler(SubProcessCompiler):
output_extension = 'junk'
def match_file(self, path):
return path.endswith('.coffee')
def compile_file(self, infile, outfile, outdated=False, force=False):
command = (("/usr/bin/env", "cat"), ("-n",), infile,)
return self.execute_command(command, stdout_captured=outfile)
class DummyCompiler(CompilerBase): class DummyCompiler(CompilerBase):
output_extension = 'js' output_extension = 'js'
...@@ -20,7 +75,7 @@ class DummyCompiler(CompilerBase): ...@@ -20,7 +75,7 @@ class DummyCompiler(CompilerBase):
return return
class CompilerTest(TestCase): class DummyCompilerTest(TestCase):
def setUp(self): def setUp(self):
default_collector.collect() default_collector.collect()
self.compiler = Compiler() self.compiler = Compiler()
...@@ -45,3 +100,76 @@ class CompilerTest(TestCase): ...@@ -45,3 +100,76 @@ class CompilerTest(TestCase):
def tearDown(self): def tearDown(self):
default_collector.clear() default_collector.clear()
settings.COMPILERS = self.old_compilers settings.COMPILERS = self.old_compilers
class CompilerStdoutTest(TestCase):
def setUp(self):
default_collector.collect()
self.compiler = Compiler()
self.old_compilers = settings.COMPILERS
settings.PIPELINE_COMPILERS = ['tests.tests.test_compiler.LineNumberingCompiler']
def test_output_path(self):
output_path = self.compiler.output_path("js/helpers.coffee", "js")
self.assertEqual(output_path, "js/helpers.js")
def test_compile(self):
paths = self.compiler.compile([_('pipeline/js/dummy.coffee')])
self.assertEqual([_('pipeline/js/dummy.junk')], list(paths))
def tearDown(self):
default_collector.clear()
settings.PIPELINE_COMPILERS = self.old_compilers
class CompilerSelfWriterTest(TestCase):
def setUp(self):
default_collector.collect()
self.compiler = Compiler()
self.old_compilers = settings.PIPELINE_COMPILERS
settings.PIPELINE_COMPILERS = ['tests.tests.test_compiler.CopyingCompiler']
def test_output_path(self):
output_path = self.compiler.output_path("js/helpers.coffee", "js")
self.assertEqual(output_path, "js/helpers.js")
def test_compile(self):
paths = self.compiler.compile([_('pipeline/js/dummy.coffee')])
default_collector.collect()
self.assertEqual([_('pipeline/js/dummy.junk')], list(paths))
def tearDown(self):
default_collector.clear()
settings.COMPILERS = self.old_compilers
class InvalidCompilerTest(TestCase):
def setUp(self):
default_collector.collect()
self.compiler = Compiler()
self.old_compilers = settings.COMPILERS
settings.COMPILERS = ['tests.tests.test_compiler.InvalidCompiler']
def test_compile(self):
self.assertRaises(CompilerError,
self.compiler.compile, [_('pipeline/js/dummy.coffee')])
def tearDown(self):
default_collector.clear()
settings.COMPILERS = self.old_compilers
class FailingCompilerTest(TestCase):
def setUp(self):
default_collector.collect()
self.compiler = Compiler()
self.old_compilers = settings.COMPILERS
settings.COMPILERS = ['tests.tests.test_compiler.FailingCompiler']
def test_compile(self):
self.assertRaises(CompilerError,
self.compiler.compile, [_('pipeline/js/dummy.coffee'),])
def tearDown(self):
default_collector.clear()
settings.COMPILERS = self.old_compilers
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase
from pipeline.conf import PipelineSettings
class TestSettings(TestCase):
def test_3unicode(self):
s = PipelineSettings(dict(), DEFAULTS={ "PIPELINE_FOO_BINARY": "env actualprogram" })
self.assertEqual(s.PIPELINE_FOO_BINARY, ('env', 'actualprogram'))
def test_2unicode(self):
s = PipelineSettings(dict(), DEFAULTS={ "PIPELINE_FOO_BINARY": u"env actualprogram" })
self.assertEqual(s.PIPELINE_FOO_BINARY, ('env', 'actualprogram'))
def test_2bytes(self):
s = PipelineSettings(dict(), DEFAULTS={ "PIPELINE_FOO_BINARY": "env actualprogram" })
self.assertEqual(s.PIPELINE_FOO_BINARY, ('env', 'actualprogram'))
def test_expected_splitting(self):
s = PipelineSettings(dict(), DEFAULTS={ "PIPELINE_FOO_BINARY": "env actualprogram" })
self.assertEqual(s.PIPELINE_FOO_BINARY, ('env', 'actualprogram'))
def test_expected_preservation(self):
s = PipelineSettings(dict(), DEFAULTS={ "PIPELINE_FOO_BINARY": r"actual\ program" })
self.assertEqual(s.PIPELINE_FOO_BINARY, ('actual program',))
def test_tuples_are_normal(self):
s = PipelineSettings(dict(), DEFAULTS={ "PIPELINE_FOO_ARGUMENTS": ("explicit", "with", "args") })
self.assertEqual(s.PIPELINE_FOO_ARGUMENTS, ('explicit', 'with', 'args'))
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment