diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -224,6 +224,8 @@
     'AphrontFormView' => 'view/form/AphrontFormView.php',
     'AphrontGlyphBarView' => 'view/widget/bars/AphrontGlyphBarView.php',
     'AphrontHTMLResponse' => 'aphront/response/AphrontHTMLResponse.php',
+    'AphrontHTTPHeaderParser' => 'aphront/headerparser/AphrontHTTPHeaderParser.php',
+    'AphrontHTTPHeaderParserTestCase' => 'aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php',
     'AphrontHTTPParameterType' => 'aphront/httpparametertype/AphrontHTTPParameterType.php',
     'AphrontHTTPProxyResponse' => 'aphront/response/AphrontHTTPProxyResponse.php',
     'AphrontHTTPSink' => 'aphront/sink/AphrontHTTPSink.php',
@@ -242,6 +244,9 @@
     'AphrontMalformedRequestException' => 'aphront/exception/AphrontMalformedRequestException.php',
     'AphrontMoreView' => 'view/layout/AphrontMoreView.php',
     'AphrontMultiColumnView' => 'view/layout/AphrontMultiColumnView.php',
+    'AphrontMultipartParser' => 'aphront/multipartparser/AphrontMultipartParser.php',
+    'AphrontMultipartParserTestCase' => 'aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php',
+    'AphrontMultipartPart' => 'aphront/multipartparser/AphrontMultipartPart.php',
     'AphrontMySQLDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLDatabaseConnection.php',
     'AphrontMySQLDatabaseConnectionTestCase' => 'infrastructure/storage/__tests__/AphrontMySQLDatabaseConnectionTestCase.php',
     'AphrontMySQLiDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php',
@@ -265,12 +270,14 @@
     'AphrontReloadResponse' => 'aphront/response/AphrontReloadResponse.php',
     'AphrontRequest' => 'aphront/AphrontRequest.php',
     'AphrontRequestExceptionHandler' => 'aphront/handler/AphrontRequestExceptionHandler.php',
+    'AphrontRequestStream' => 'aphront/requeststream/AphrontRequestStream.php',
     'AphrontRequestTestCase' => 'aphront/__tests__/AphrontRequestTestCase.php',
     'AphrontResponse' => 'aphront/response/AphrontResponse.php',
     'AphrontResponseProducerInterface' => 'aphront/interface/AphrontResponseProducerInterface.php',
     'AphrontRoutingMap' => 'aphront/site/AphrontRoutingMap.php',
     'AphrontRoutingResult' => 'aphront/site/AphrontRoutingResult.php',
     'AphrontSchemaQueryException' => 'infrastructure/storage/exception/AphrontSchemaQueryException.php',
+    'AphrontScopedUnguardedWriteCapability' => 'aphront/writeguard/AphrontScopedUnguardedWriteCapability.php',
     'AphrontSelectHTTPParameterType' => 'aphront/httpparametertype/AphrontSelectHTTPParameterType.php',
     'AphrontSideNavFilterView' => 'view/layout/AphrontSideNavFilterView.php',
     'AphrontSite' => 'aphront/site/AphrontSite.php',
@@ -286,6 +293,7 @@
     'AphrontUserListHTTPParameterType' => 'aphront/httpparametertype/AphrontUserListHTTPParameterType.php',
     'AphrontView' => 'view/AphrontView.php',
     'AphrontWebpageResponse' => 'aphront/response/AphrontWebpageResponse.php',
+    'AphrontWriteGuard' => 'aphront/writeguard/AphrontWriteGuard.php',
     'ArcanistConduitAPIMethod' => 'applications/arcanist/conduit/ArcanistConduitAPIMethod.php',
     'AuditConduitAPIMethod' => 'applications/audit/conduit/AuditConduitAPIMethod.php',
     'AuditQueryConduitAPIMethod' => 'applications/audit/conduit/AuditQueryConduitAPIMethod.php',
@@ -6170,6 +6178,8 @@
     'AphrontFormView' => 'AphrontView',
     'AphrontGlyphBarView' => 'AphrontBarView',
     'AphrontHTMLResponse' => 'AphrontResponse',
+    'AphrontHTTPHeaderParser' => 'Phobject',
+    'AphrontHTTPHeaderParserTestCase' => 'PhutilTestCase',
     'AphrontHTTPParameterType' => 'Phobject',
     'AphrontHTTPProxyResponse' => 'AphrontResponse',
     'AphrontHTTPSink' => 'Phobject',
@@ -6188,6 +6198,9 @@
     'AphrontMalformedRequestException' => 'AphrontException',
     'AphrontMoreView' => 'AphrontView',
     'AphrontMultiColumnView' => 'AphrontView',
+    'AphrontMultipartParser' => 'Phobject',
+    'AphrontMultipartParserTestCase' => 'PhutilTestCase',
+    'AphrontMultipartPart' => 'Phobject',
     'AphrontMySQLDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection',
     'AphrontMySQLDatabaseConnectionTestCase' => 'PhabricatorTestCase',
     'AphrontMySQLiDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection',
@@ -6214,11 +6227,13 @@
     'AphrontReloadResponse' => 'AphrontRedirectResponse',
     'AphrontRequest' => 'Phobject',
     'AphrontRequestExceptionHandler' => 'Phobject',
+    'AphrontRequestStream' => 'Phobject',
     'AphrontRequestTestCase' => 'PhabricatorTestCase',
     'AphrontResponse' => 'Phobject',
     'AphrontRoutingMap' => 'Phobject',
     'AphrontRoutingResult' => 'Phobject',
     'AphrontSchemaQueryException' => 'AphrontQueryException',
+    'AphrontScopedUnguardedWriteCapability' => 'Phobject',
     'AphrontSelectHTTPParameterType' => 'AphrontHTTPParameterType',
     'AphrontSideNavFilterView' => 'AphrontView',
     'AphrontSite' => 'Phobject',
@@ -6237,6 +6252,7 @@
       'PhutilSafeHTMLProducerInterface',
     ),
     'AphrontWebpageResponse' => 'AphrontHTMLResponse',
+    'AphrontWriteGuard' => 'Phobject',
     'ArcanistConduitAPIMethod' => 'ConduitAPIMethod',
     'AuditConduitAPIMethod' => 'ConduitAPIMethod',
     'AuditQueryConduitAPIMethod' => 'AuditConduitAPIMethod',
diff --git a/src/aphront/headerparser/AphrontHTTPHeaderParser.php b/src/aphront/headerparser/AphrontHTTPHeaderParser.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/headerparser/AphrontHTTPHeaderParser.php
@@ -0,0 +1,150 @@
+<?php
+
+final class AphrontHTTPHeaderParser extends Phobject {
+
+  private $name;
+  private $content;
+  private $pairs;
+
+  public function parseRawHeader($raw_header) {
+    $this->name = null;
+    $this->content = null;
+
+    $parts = explode(':', $raw_header, 2);
+    $this->name = trim($parts[0]);
+    if (count($parts) > 1) {
+      $this->content = trim($parts[1]);
+    }
+
+    $this->pairs = null;
+
+    return $this;
+  }
+
+  public function getHeaderName() {
+    $this->requireParse();
+    return $this->name;
+  }
+
+  public function getHeaderContent() {
+    $this->requireParse();
+    return $this->content;
+  }
+
+  public function getHeaderContentAsPairs() {
+    $content = $this->getHeaderContent();
+
+
+    $state = 'prekey';
+    $length = strlen($content);
+
+    $pair_name = null;
+    $pair_value = null;
+
+    $pairs = array();
+    $ii = 0;
+    while ($ii < $length) {
+      $c = $content[$ii];
+
+      switch ($state) {
+        case 'prekey';
+          // We're eating space in front of a key.
+          if ($c == ' ') {
+            $ii++;
+            break;
+          }
+          $pair_name = '';
+          $state = 'key';
+          break;
+        case 'key';
+          // We're parsing a key name until we find "=" or ";".
+          if ($c == ';') {
+            $state = 'done';
+            break;
+          }
+
+          if ($c == '=') {
+            $ii++;
+            $state = 'value';
+            break;
+          }
+
+          $ii++;
+          $pair_name .= $c;
+          break;
+        case 'value':
+          // We found an "=", so now figure out if the value is quoted
+          // or not.
+          if ($c == '"') {
+            $ii++;
+            $state = 'quoted';
+            break;
+          }
+          $state = 'unquoted';
+          break;
+        case 'quoted':
+          // We're in a quoted string, parse until we find the closing quote.
+          if ($c == '"') {
+            $ii++;
+            $state = 'done';
+            break;
+          }
+
+          $ii++;
+          $pair_value .= $c;
+          break;
+        case 'unquoted':
+          // We're in an unquoted string, parse until we find a space or a
+          // semicolon.
+          if ($c == ' ' || $c == ';') {
+            $state = 'done';
+            break;
+          }
+          $ii++;
+          $pair_value .= $c;
+          break;
+        case 'done':
+          // We parsed something, so eat any trailing whitespace and semicolons
+          // and look for a new value.
+          if ($c == ' ' || $c == ';') {
+            $ii++;
+            break;
+          }
+
+          $pairs[] = array(
+            $pair_name,
+            $pair_value,
+          );
+
+          $pair_name = null;
+          $pair_value = null;
+
+          $state = 'prekey';
+          break;
+      }
+    }
+
+    if ($state == 'quoted') {
+      throw new Exception(
+        pht(
+          'Header has unterminated double quote for key "%s".',
+          $pair_name));
+    }
+
+    if ($pair_name !== null) {
+      $pairs[] = array(
+        $pair_name,
+        $pair_value,
+      );
+    }
+
+    return $pairs;
+  }
+
+  private function requireParse() {
+    if ($this->name === null) {
+      throw new PhutilInvalidStateException('parseRawHeader');
+    }
+  }
+
+}
diff --git a/src/aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php b/src/aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php
@@ -0,0 +1,108 @@
+<?php
+
+final class AphrontHTTPHeaderParserTestCase extends PhutilTestCase {
+
+  public function testHeaderParser() {
+    $cases = array(
+      array(
+        'Key: x; y; z',
+        'Key',
+        'x; y; z',
+        array(
+          array('x', null),
+          array('y', null),
+          array('z', null),
+        ),
+      ),
+      array(
+        'Content-Disposition: form-data; name="label"',
+        'Content-Disposition',
+        'form-data; name="label"',
+        array(
+          array('form-data', null),
+          array('name', 'label'),
+        ),
+      ),
+      array(
+        'Content-Type: multipart/form-data; charset=utf-8',
+        'Content-Type',
+        'multipart/form-data; charset=utf-8',
+        array(
+          array('multipart/form-data', null),
+          array('charset', 'utf-8'),
+        ),
+      ),
+      array(
+        'Content-Type: application/octet-stream; charset="ut',
+        'Content-Type',
+        'application/octet-stream; charset="ut',
+        false,
+      ),
+      array(
+        'Content-Type: multipart/form-data; boundary=ABCDEFG',
+        'Content-Type',
+        'multipart/form-data; boundary=ABCDEFG',
+        array(
+          array('multipart/form-data', null),
+          array('boundary', 'ABCDEFG'),
+        ),
+      ),
+      array(
+        'Content-Type: multipart/form-data; boundary="ABCDEFG"',
+        'Content-Type',
+        'multipart/form-data; boundary="ABCDEFG"',
+        array(
+          array('multipart/form-data', null),
+          array('boundary', 'ABCDEFG'),
+        ),
+      ),
+    );
+
+    foreach ($cases as $case) {
+      $input = $case[0];
+      $expect_name = $case[1];
+      $expect_content = $case[2];
+
+      $parser = id(new AphrontHTTPHeaderParser())
+        ->parseRawHeader($input);
+
+      $actual_name = $parser->getHeaderName();
+      $actual_content = $parser->getHeaderContent();
+
+      $this->assertEqual(
+        $expect_name,
+        $actual_name,
+        pht('Header name for: %s', $input));
+
+      $this->assertEqual(
+        $expect_content,
+        $actual_content,
+        pht('Header content for: %s', $input));
+
+      if (isset($case[3])) {
+        $expect_pairs = $case[3];
+
+        $caught = null;
+        try {
+          $actual_pairs = $parser->getHeaderContentAsPairs();
+        } catch (Exception $ex) {
+          $caught = $ex;
+        }
+
+        if ($expect_pairs === false) {
+          $this->assertEqual(
+            true,
+            ($caught instanceof Exception),
+            pht('Expect exception for header pairs of: %s', $input));
+        } else {
+          $this->assertEqual(
+            $expect_pairs,
+            $actual_pairs,
+            pht('Header pairs for: %s', $input));
+        }
+      }
+    }
+  }
+
+
+}
diff --git a/src/aphront/multipartparser/AphrontMultipartParser.php b/src/aphront/multipartparser/AphrontMultipartParser.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/multipartparser/AphrontMultipartParser.php
@@ -0,0 +1,249 @@
+<?php
+
+final class AphrontMultipartParser extends Phobject {
+
+  private $contentType;
+  private $boundary;
+
+  private $buffer;
+  private $body;
+  private $state;
+
+  private $part;
+  private $parts;
+
+  public function setContentType($content_type) {
+    $this->contentType = $content_type;
+    return $this;
+  }
+
+  public function getContentType() {
+    return $this->contentType;
+  }
+
+  public function beginParse() {
+    $content_type = $this->getContentType();
+    if ($content_type === null) {
+      throw new PhutilInvalidStateException('setContentType');
+    }
+
+    if (!preg_match('(^multipart/form-data)', $content_type)) {
+      throw new Exception(
+        pht(
+          'Expected "multipart/form-data" content type when executing a '.
+          'multipart body read.'));
+    }
+
+    $type_parts = preg_split('(\s*;\s*)', $content_type);
+    $boundary = null;
+    foreach ($type_parts as $type_part) {
+      $matches = null;
+      if (preg_match('(^boundary=(.*))', $type_part, $matches)) {
+        $boundary = $matches[1];
+        break;
+      }
+    }
+
+    if ($boundary === null) {
+      throw new Exception(
+        pht('Received "multipart/form-data" request with no "boundary".'));
+    }
+
+    $this->parts = array();
+    $this->part = null;
+
+    $this->buffer = '';
+    $this->boundary = $boundary;
+
+    // We're looking for a (usually empty) body before the first boundary.
+    $this->state = 'bodynewline';
+  }
+
+  public function continueParse($bytes) {
+    $this->buffer .= $bytes;
+
+    $continue = true;
+    while ($continue) {
+      switch ($this->state) {
+        case 'endboundary':
+          // We've just parsed a boundary. Next, we expect either "--" (which
+          // indicates we've reached the end of the parts) or "\r\n" (which
+          // indicates we should read the headers for the next part).
+
+          if (strlen($this->buffer) < 2) {
+            // We don't have enough bytes yet, so wait for more.
+            $continue = false;
+            break;
+          }
+
+          if (!strncmp($this->buffer, '--', 2)) {
+            // This is "--" after a boundary, so we're done. We'll read the
+            // rest of the body (the "epilogue") and discard it.
+            $this->buffer = substr($this->buffer, 2);
+            $this->state = 'epilogue';
+
+            $this->part = null;
+            break;
+          }
+
+          if (!strncmp($this->buffer, "\r\n", 2)) {
+            // This is "\r\n" after a boundary, so we're going to going to
+            // read the headers for a part.
+            $this->buffer = substr($this->buffer, 2);
+            $this->state = 'header';
+
+            // Create the object to hold the part we're about to read.
+            $part = new AphrontMultipartPart();
+            $this->parts[] = $part;
+            $this->part = $part;
+            break;
+          }
+
+          throw new Exception(
+            pht('Expected "\r\n" or "--" after multipart data boundary.'));
+        case 'header':
+          // We've just parsed a boundary, followed by "\r\n". We are going
+          // to read the headers for this part. They are in the form of HTTP
+          // headers and terminated by "\r\n". The section is terminated by
+          // a line with no header on it.
+
+          if (strlen($this->buffer) < 2) {
+            // We don't have enough data to find a "\r\n", so wait for more.
+            $continue = false;
+            break;
+          }
+
+          if (!strncmp("\r\n", $this->buffer, 2)) {
+            // This line immediately began "\r\n", so we're done with parsing
+            // headers. Start parsing the body.
+            $this->buffer = substr($this->buffer, 2);
+            $this->state = 'body';
+            break;
+          }
+
+          // This is an actual header, so look for the end of it.
+          $header_len = strpos($this->buffer, "\r\n");
+          if ($header_len === false) {
+            // We don't have a full header yet, so wait for more data.
+            $continue = false;
+            break;
+          }
+
+          $header_buf = substr($this->buffer, 0, $header_len);
+          $this->part->appendRawHeader($header_buf);
+
+          $this->buffer = substr($this->buffer, $header_len + 2);
+          break;
+        case 'body':
+          // We've parsed a boundary and headers, and are parsing the data for
+          // this part. The data is terminated by "\r\n--", then the boundary.
+
+          // We'll look for "\r\n", then switch to the "bodynewline" state if
+          // we find it.
+
+          $marker = "\r";
+          $marker_pos = strpos($this->buffer, $marker);
+
+          if ($marker_pos === false) {
+            // There's no "\r" anywhere in the buffer, so we can just read it
+            // as provided. Then, since we read all the data, we're done until
+            // we get more.
+
+            // Note that if we're in the preamble, we won't have a "part"
+            // object and will just discard the data.
+            if ($this->part) {
+              $this->part->appendData($this->buffer);
+            }
+            $this->buffer = '';
+            $continue = false;
+            break;
+          }
+
+          if ($marker_pos > 0) {
+            // If there are bytes before the "\r",
+            if ($this->part) {
+              $this->part->appendData(substr($this->buffer, 0, $marker_pos));
+            }
+            $this->buffer = substr($this->buffer, $marker_pos);
+          }
+
+          $expect = "\r\n";
+          $expect_len = strlen($expect);
+          if (strlen($this->buffer) < $expect_len) {
+            // We don't have enough bytes yet to know if this is "\r\n"
+            // or not.
+            $continue = false;
+            break;
+          }
+
+          if (strncmp($this->buffer, $expect, $expect_len)) {
+            // The next two bytes aren't "\r\n", so eat them and go looking
+            // for more newlines.
+            if ($this->part) {
+              $this->part->appendData(substr($this->buffer, 0, $expect_len));
+            }
+            $this->buffer = substr($this->buffer, $expect_len);
+            break;
+          }
+
+          // Eat the "\r\n".
+          $this->buffer = substr($this->buffer, $expect_len);
+          $this->state = 'bodynewline';
+          break;
+        case 'bodynewline':
+          // We've parsed a newline in a body, or we just started parsing the
+          // request. In either case, we're looking for "--", then the boundary.
+          // If we find it, this section is done. If we don't, we consume the
+          // bytes and move on.
+
+          $expect = '--'.$this->boundary;
+          $expect_len = strlen($expect);
+
+          if (strlen($this->buffer) < $expect_len) {
+            // We don't have enough bytes yet, so wait for more.
+            $continue = false;
+            break;
+          }
+
+          if (strncmp($this->buffer, $expect, $expect_len)) {
+            // This wasn't the boundary, so return to the "body" state and
+            // consume it. (But first, we need to append the "\r\n" which we
+            // ate earlier.)
+            if ($this->part) {
+              $this->part->appendData("\r\n");
+            }
+            $this->state = 'body';
+            break;
+          }
+
+          // This is the boundary, so toss it and move on.
+          $this->buffer = substr($this->buffer, $expect_len);
+          $this->state = 'endboundary';
+          break;
+        case 'epilogue':
+          // We just discard any epilogue.
+          $this->buffer = '';
+          $continue = false;
+          break;
+        default:
+          throw new Exception(
+            pht(
+              'Unknown parser state "%s".\n',
+              $this->state));
+      }
+    }
+  }
+
+  public function endParse() {
+    if ($this->state !== 'epilogue') {
+      throw new Exception(
+        pht(
+          'Expected "multipart/form-data" parse to end '.
+          'in state "epilogue".'));
+    }
+
+    return $this->parts;
+  }
+
+
+}
diff --git a/src/aphront/multipartparser/AphrontMultipartPart.php b/src/aphront/multipartparser/AphrontMultipartPart.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/multipartparser/AphrontMultipartPart.php
@@ -0,0 +1,96 @@
+<?php
+
+final class AphrontMultipartPart extends Phobject {
+
+  private $headers = array();
+  private $value = '';
+
+  private $name;
+  private $filename;
+  private $tempFile;
+  private $byteSize = 0;
+
+  public function appendRawHeader($bytes) {
+    $parser = id(new AphrontHTTPHeaderParser())
+      ->parseRawHeader($bytes);
+
+    $header_name = $parser->getHeaderName();
+
+    $this->headers[] = array(
+      $header_name,
+      $parser->getHeaderContent(),
+    );
+
+    if (strtolower($header_name) === 'content-disposition') {
+      $pairs = $parser->getHeaderContentAsPairs();
+      foreach ($pairs as $pair) {
+        list($key, $value) = $pair;
+        switch ($key) {
+          case 'filename':
+            $this->filename = $value;
+            break;
+          case 'name':
+            $this->name = $value;
+            break;
+        }
+      }
+    }
+
+    return $this;
+  }
+
+  public function appendData($bytes) {
+    $this->byteSize += strlen($bytes);
+
+    if ($this->isVariable()) {
+      $this->value .= $bytes;
+    } else {
+      if (!$this->tempFile) {
+        $this->tempFile = new TempFile(getmypid().'.upload');
+      }
+      Filesystem::appendFile($this->tempFile, $bytes);
+    }
+
+    return $this;
+  }
+
+  public function isVariable() {
+    return ($this->filename === null);
+  }
+
+  public function getName() {
+    return $this->name;
+  }
+
+  public function getVariableValue() {
+    if (!$this->isVariable()) {
+      throw new Exception(pht('This part is not a variable!'));
+    }
+
+    return $this->value;
+  }
+
+  public function getPHPFileDictionary() {
+    if (!$this->tempFile) {
+      $this->appendData('');
+    }
+
+    $mime_type = 'application/octet-stream';
+    foreach ($this->headers as $header) {
+      list($name, $value) = $header;
+      if (strtolower($name) == 'content-type') {
+        $mime_type = $value;
+        break;
+      }
+    }
+
+    return array(
+      'name' => $this->filename,
+      'type' => $mime_type,
+      'tmp_name' => (string)$this->tempFile,
+      'error' => 0,
+      'size' => $this->byteSize,
+    );
+  }
+
+}
diff --git a/src/aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php b/src/aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php
@@ -0,0 +1,45 @@
+<?php
+
+final class AphrontMultipartParserTestCase extends PhutilTestCase {
+
+  public function testParser() {
+    $map = array(
+      array(
+        'data' => 'simple.txt',
+        'variables' => array(
+          array('a', 'b'),
+        ),
+      ),
+    );
+
+    $data_dir = dirname(__FILE__).'/data/';
+    foreach ($map as $test_case) {
+      $data = Filesystem::readFile($data_dir.$test_case['data']);
+      $data = str_replace("\n", "\r\n", $data);
+
+      $parser = id(new AphrontMultipartParser())
+        ->setContentType('multipart/form-data; boundary=ABCDEFG');
+      $parser->beginParse();
+      $parser->continueParse($data);
+      $parts = $parser->endParse();
+
+      $variables = array();
+      foreach ($parts as $part) {
+        if (!$part->isVariable()) {
+          continue;
+        }
+
+        $variables[] = array(
+          $part->getName(),
+          $part->getVariableValue(),
+        );
+      }
+
+      $expect_variables = idx($test_case, 'variables', array());
+      $this->assertEqual($expect_variables, $variables);
+    }
+  }
+
+
+
+}
diff --git a/src/aphront/multipartparser/__tests__/data/simple.txt b/src/aphront/multipartparser/__tests__/data/simple.txt
new file mode 100644
--- /dev/null
+++ b/src/aphront/multipartparser/__tests__/data/simple.txt
@@ -0,0 +1,5 @@
+--ABCDEFG
+Content-Disposition: form-data; name="a"
+
+b
+--ABCDEFG--
diff --git a/src/aphront/requeststream/AphrontRequestStream.php b/src/aphront/requeststream/AphrontRequestStream.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/requeststream/AphrontRequestStream.php
@@ -0,0 +1,92 @@
+<?php
+
+final class AphrontRequestStream extends Phobject {
+
+  private $encoding;
+  private $stream;
+  private $closed;
+  private $iterator;
+
+  public function setEncoding($encoding) {
+    $this->encoding = $encoding;
+    return $this;
+  }
+
+  public function getEncoding() {
+    return $this->encoding;
+  }
+
+  public function getIterator() {
+    if (!$this->iterator) {
+      $this->iterator = new PhutilStreamIterator($this->getStream());
+    }
+    return $this->iterator;
+  }
+
+  public function readData() {
+    if (!$this->iterator) {
+      $iterator = $this->getIterator();
+      $iterator->rewind();
+    } else {
+      $iterator = $this->getIterator();
+    }
+
+    if (!$iterator->valid()) {
+      return null;
+    }
+
+    $data = $iterator->current();
+    $iterator->next();
+
+    return $data;
+  }
+
+  private function getStream() {
+    if (!$this->stream) {
+      $this->stream = $this->newStream();
+    }
+
+    return $this->stream;
+  }
+
+  private function newStream() {
+    $stream = fopen('php://input', 'rb');
+    if (!$stream) {
+      throw new Exception(
+        pht(
+          'Failed to open stream "%s" for reading.',
+          'php://input'));
+    }
+
+    $encoding = $this->getEncoding();
+    if ($encoding === 'gzip') {
+      // This parameter is magic. Values 0-15 express a time/memory tradeoff,
+      // but the largest value (15) corresponds to only 32KB of memory and
+      // data encoded with a smaller window size than the one we pass can not
+      // be decompressed. Always pass the maximum window size.
+
+      // Additionally, you can add 16 (to enable gzip) or 32 (to enable both
+      // gzip and zlib). Add 32 to support both.
+      $zlib_window = 15 + 32;
+
+      $ok = stream_filter_append(
+        $stream,
+        'zlib.inflate',
+        STREAM_FILTER_READ,
+        array(
+          'window' => $zlib_window,
+        ));
+      if (!$ok) {
+        throw new Exception(
+          pht(
+            'Failed to append filter "%s" to input stream while processing '.
+            'a request with "%s" encoding.',
+            'zlib.inflate',
+            $encoding));
+      }
+    }
+
+    return $stream;
+  }
+
+}
diff --git a/src/aphront/writeguard/AphrontScopedUnguardedWriteCapability.php b/src/aphront/writeguard/AphrontScopedUnguardedWriteCapability.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/writeguard/AphrontScopedUnguardedWriteCapability.php
@@ -0,0 +1,9 @@
+<?php
+
+final class AphrontScopedUnguardedWriteCapability extends Phobject {
+
+  public function __destruct() {
+    AphrontWriteGuard::endUnguardedWrites();
+  }
+
+}
diff --git a/src/aphront/writeguard/AphrontWriteGuard.php b/src/aphront/writeguard/AphrontWriteGuard.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/writeguard/AphrontWriteGuard.php
@@ -0,0 +1,267 @@
+<?php
+
+/**
+ * Guard writes against CSRF. The Aphront structure takes care of most of this
+ * for you, you just need to call:
+ *
+ *    AphrontWriteGuard::willWrite();
+ *
+ * ...before executing a write against any new kind of storage engine. MySQL
+ * databases and the default file storage engines are already covered, but if
+ * you introduce new types of datastores make sure their writes are guarded. If
+ * you don't guard writes and make a mistake doing CSRF checks in a controller,
+ * a CSRF vulnerability can escape undetected.
+ *
+ * If you need to execute writes on a page which doesn't have CSRF tokens (for
+ * example, because you need to do logging), you can temporarily disable the
+ * write guard by calling:
+ *
+ *    AphrontWriteGuard::beginUnguardedWrites();
+ *    do_logging_write();
+ *    AphrontWriteGuard::endUnguardedWrites();
+ *
+ * This is dangerous, because it disables the backup layer of CSRF protection
+ * this class provides. You should need this only very, very rarely.
+ *
+ * @task protect  Protecting Writes
+ * @task disable  Disabling Protection
+ * @task manage   Managing Write Guards
+ * @task internal Internals
+ */
+final class AphrontWriteGuard extends Phobject {
+
+  private static $instance;
+  private static $allowUnguardedWrites = false;
+
+  private $callback;
+  private $allowDepth = 0;
+
+
+/* -(  Managing Write Guards  )---------------------------------------------- */
+
+
+  /**
+   * Construct a new write guard for a request. Only one write guard may be
+   * active at a time. You must explicitly call @{method:dispose} when you are
+   * done with a write guard:
+   *
+   *    $guard = new AphrontWriteGuard($callback);
+   *    // ...
+   *    $guard->dispose();
+   *
+   * Normally, you do not need to manage guards yourself -- the Aphront stack
+   * handles it for you.
+   *
+   * This class accepts a callback, which will be invoked when a write is
+   * attempted. The callback should validate the presence of a CSRF token in
+   * the request, or abort the request (e.g., by throwing an exception) if a
+   * valid token isn't present.
+   *
+   * @param   callable CSRF callback.
+   * @return  this
+   * @task    manage
+   */
+  public function __construct($callback) {
+    if (self::$instance) {
+      throw new Exception(
+        pht(
+          'An %s already exists. Dispose of the previous guard '.
+          'before creating a new one.',
+          __CLASS__));
+    }
+    if (self::$allowUnguardedWrites) {
+      throw new Exception(
+        pht(
+          'An %s is being created in a context which permits '.
+          'unguarded writes unconditionally. This is not allowed and '.
+          'indicates a serious error.',
+          __CLASS__));
+    }
+    $this->callback = $callback;
+    self::$instance = $this;
+  }
+
+
+  /**
+   * Dispose of the active write guard. You must call this method when you are
+   * done with a write guard. You do not normally need to call this yourself.
+   *
+   * @return void
+   * @task manage
+   */
+  public function dispose() {
+    if (!self::$instance) {
+      throw new Exception(pht(
+        'Attempting to dispose of write guard, but no write guard is active!'));
+    }
+
+    if ($this->allowDepth > 0) {
+      throw new Exception(
+        pht(
+          'Imbalanced %s: more %s calls than %s calls.',
+          __CLASS__,
+          'beginUnguardedWrites()',
+          'endUnguardedWrites()'));
+    }
+    self::$instance = null;
+  }
+
+
+  /**
+   * Determine if there is an active write guard.
+   *
+   * @return bool
+   * @task manage
+   */
+  public static function isGuardActive() {
+    return (bool)self::$instance;
+  }
+
+  /**
+   * Return on instance of AphrontWriteGuard if it's active, or null
+   *
+   * @return AphrontWriteGuard|null
+   */
+  public static function getInstance() {
+    return self::$instance;
+  }
+
+
+/* -(  Protecting Writes  )-------------------------------------------------- */
+
+
+  /**
+   * Declare intention to perform a write, validating that writes are allowed.
+   * You should call this method before executing a write whenever you implement
+   * a new storage engine where information can be permanently kept.
+   *
+   * Writes are permitted if:
+   *
+   *   - The request has valid CSRF tokens.
+   *   - Unguarded writes have been temporarily enabled by a call to
+   *     @{method:beginUnguardedWrites}.
+   *   - All write guarding has been disabled with
+   *     @{method:allowDangerousUnguardedWrites}.
+   *
+   * If none of these conditions are true, this method will throw and prevent
+   * the write.
+   *
+   * @return void
+   * @task protect
+   */
+  public static function willWrite() {
+    if (!self::$instance) {
+      if (!self::$allowUnguardedWrites) {
+        throw new Exception(
+          pht(
+            'Unguarded write! There must be an active %s to perform writes.',
+            __CLASS__));
+      } else {
+        // Unguarded writes are being allowed unconditionally.
+        return;
+      }
+    }
+
+    $instance = self::$instance;
+    if ($instance->allowDepth == 0) {
+      call_user_func($instance->callback);
+    }
+  }
+
+
+/* -(  Disabling Write Protection  )----------------------------------------- */
+
+
+  /**
+   * Enter a scope which permits unguarded writes. This works like
+   * @{method:beginUnguardedWrites} but returns an object which will end
+   * the unguarded write scope when its __destruct() method is called. This
+   * is useful to more easily handle exceptions correctly in unguarded write
+   * blocks:
+   *
+   *   // Restores the guard even if do_logging() throws.
+   *   function unguarded_scope() {
+   *     $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
+   *     do_logging();
+   *   }
+   *
+   * @return AphrontScopedUnguardedWriteCapability Object which ends unguarded
+   *            writes when it leaves scope.
+   * @task disable
+   */
+  public static function beginScopedUnguardedWrites() {
+    self::beginUnguardedWrites();
+    return new AphrontScopedUnguardedWriteCapability();
+  }
+
+
+  /**
+   * Begin a block which permits unguarded writes. You should use this very
+   * sparingly, and only for things like logging where CSRF is not a concern.
+   *
+   * You must pair every call to @{method:beginUnguardedWrites} with a call to
+   * @{method:endUnguardedWrites}:
+   *
+   *   AphrontWriteGuard::beginUnguardedWrites();
+   *   do_logging();
+   *   AphrontWriteGuard::endUnguardedWrites();
+   *
+   * @return void
+   * @task disable
+   */
+  public static function beginUnguardedWrites() {
+    if (!self::$instance) {
+      return;
+    }
+    self::$instance->allowDepth++;
+  }
+
+  /**
+   * Declare that you have finished performing unguarded writes. You must
+   * call this exactly once for each call to @{method:beginUnguardedWrites}.
+   *
+   * @return void
+   * @task disable
+   */
+  public static function endUnguardedWrites() {
+    if (!self::$instance) {
+      return;
+    }
+    if (self::$instance->allowDepth <= 0) {
+      throw new Exception(
+        pht(
+          'Imbalanced %s: more %s calls than %s calls.',
+          __CLASS__,
+          'endUnguardedWrites()',
+          'beginUnguardedWrites()'));
+    }
+    self::$instance->allowDepth--;
+  }
+
+
+  /**
+   * Allow execution of unguarded writes. This is ONLY appropriate for use in
+   * script contexts or other contexts where you are guaranteed to never be
+   * vulnerable to CSRF concerns. Calling this method is EXTREMELY DANGEROUS
+   * if you do not understand the consequences.
+   *
+   * If you need to perform unguarded writes on an otherwise guarded workflow
+   * which is vulnerable to CSRF, use @{method:beginUnguardedWrites}.
+   *
+   * @return void
+   * @task disable
+   */
+  public static function allowDangerousUnguardedWrites($allow) {
+    if (self::$instance) {
+      throw new Exception(
+        pht(
+          'You can not unconditionally disable %s by calling %s while a write '.
+          'guard is active. Use %s to temporarily allow unguarded writes.',
+          __CLASS__,
+          __FUNCTION__.'()',
+          'beginUnguardedWrites()'));
+    }
+    self::$allowUnguardedWrites = true;
+  }
+
+}