From 4e366e3bab6d0a236e828b07642971a37eb0cbcd Mon Sep 17 00:00:00 2001
From: Mikhail Golenkov <mikhailgolenkov@catalyst-au.net>
Date: Tue, 7 Jul 2020 15:28:07 +1000
Subject: [PATCH 1/7] MDL-46375 core_files: Split parts of file_storage into
 new file system

This change moves all operations which deal with the fetching/updating, or
setting of files from the file_storage class into a new file_system class.

A new file_system can be specified in the config.php and used to replace
all relevant methods in order to move the file system component to an
alternative solution.
---
 composer.json                                 |    3 +-
 composer.lock                                 | 1099 ++++++++++++-----
 lib/filestorage/file_storage.php              |  407 ++----
 lib/filestorage/file_system.php               |  561 +++++++++
 lib/filestorage/file_system_filedir.php       |  515 ++++++++
 lib/filestorage/stored_file.php               |  154 +--
 lib/filestorage/tests/file_storage_test.php   |   57 +-
 .../tests/file_system_filedir_test.php        | 1063 ++++++++++++++++
 lib/filestorage/tests/file_system_test.php    | 1091 ++++++++++++++++
 lib/filestorage/tests/fixtures/test.tgz       |  Bin 0 -> 152 bytes
 lib/moodlelib.php                             |   21 +-
 question/format/blackboard_six/formatbase.php |    2 +-
 repository/lib.php                            |   13 +-
 13 files changed, 4215 insertions(+), 771 deletions(-)
 create mode 100644 lib/filestorage/file_system.php
 create mode 100644 lib/filestorage/file_system_filedir.php
 create mode 100644 lib/filestorage/tests/file_system_filedir_test.php
 create mode 100644 lib/filestorage/tests/file_system_test.php
 create mode 100644 lib/filestorage/tests/fixtures/test.tgz

diff --git a/composer.json b/composer.json
index a9dbb44b879..a4075450ee8 100644
--- a/composer.json
+++ b/composer.json
@@ -4,6 +4,7 @@
         "phpunit/dbunit": "1.4.1",
         "sebastian/environment": "1.3.7",
         "sebastian/recursion-context": "1.0.2",
-        "moodlehq/behat-extension": "3.31.7"
+        "moodlehq/behat-extension": "3.31.7",
+        "mikey179/vfsstream": "^1.6"
     }
 }
diff --git a/composer.lock b/composer.lock
index 66f0aa252cd..211b56ec4a1 100644
--- a/composer.lock
+++ b/composer.lock
@@ -1,10 +1,10 @@
 {
     "_readme": [
         "This file locks the dependencies of your project to a known state",
-        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "3a6c6907fe5405dbbd8ae2ba209fa942",
+    "content-hash": "8bd92937367c6dc6600acb6ecd38f3fa",
     "packages": [],
     "packages-dev": [
         {
@@ -89,16 +89,16 @@
         },
         {
             "name": "behat/gherkin",
-            "version": "v4.5.1",
+            "version": "v4.6.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Behat/Gherkin.git",
-                "reference": "74ac03d52c5e23ad8abd5c5cce4ab0e8dc1b530a"
+                "reference": "51ac4500c4dc30cbaaabcd2f25694299df666a31"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Behat/Gherkin/zipball/74ac03d52c5e23ad8abd5c5cce4ab0e8dc1b530a",
-                "reference": "74ac03d52c5e23ad8abd5c5cce4ab0e8dc1b530a",
+                "url": "https://api.github.com/repos/Behat/Gherkin/zipball/51ac4500c4dc30cbaaabcd2f25694299df666a31",
+                "reference": "51ac4500c4dc30cbaaabcd2f25694299df666a31",
                 "shasum": ""
             },
             "require": {
@@ -106,8 +106,8 @@
             },
             "require-dev": {
                 "phpunit/phpunit": "~4.5|~5",
-                "symfony/phpunit-bridge": "~2.7|~3",
-                "symfony/yaml": "~2.3|~3"
+                "symfony/phpunit-bridge": "~2.7|~3|~4",
+                "symfony/yaml": "~2.3|~3|~4"
             },
             "suggest": {
                 "symfony/yaml": "If you want to parse features, represented in YAML files"
@@ -144,39 +144,42 @@
                 "gherkin",
                 "parser"
             ],
-            "time": "2017-08-30T11:04:43+00:00"
+            "time": "2020-03-17T14:03:26+00:00"
         },
         {
             "name": "behat/mink",
-            "version": "v1.7.1",
+            "version": "v1.8.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/minkphp/Mink.git",
-                "reference": "e6930b9c74693dff7f4e58577e1b1743399f3ff9"
+                "reference": "07c6a9fe3fa98c2de074b25d9ed26c22904e3887"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/minkphp/Mink/zipball/e6930b9c74693dff7f4e58577e1b1743399f3ff9",
-                "reference": "e6930b9c74693dff7f4e58577e1b1743399f3ff9",
+                "url": "https://api.github.com/repos/minkphp/Mink/zipball/07c6a9fe3fa98c2de074b25d9ed26c22904e3887",
+                "reference": "07c6a9fe3fa98c2de074b25d9ed26c22904e3887",
                 "shasum": ""
             },
             "require": {
                 "php": ">=5.3.1",
-                "symfony/css-selector": "~2.1|~3.0"
+                "symfony/css-selector": "^2.7|^3.0|^4.0|^5.0"
             },
             "require-dev": {
-                "symfony/phpunit-bridge": "~2.7|~3.0"
+                "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20",
+                "symfony/debug": "^2.7|^3.0|^4.0",
+                "symfony/phpunit-bridge": "^3.4.38 || ^5.0.5"
             },
             "suggest": {
                 "behat/mink-browserkit-driver": "extremely fast headless driver for Symfony\\Kernel-based apps (Sf2, Silex)",
                 "behat/mink-goutte-driver": "fast headless driver for any app without JS emulation",
                 "behat/mink-selenium2-driver": "slow, but JS-enabled driver for any app (requires Selenium2)",
-                "behat/mink-zombie-driver": "fast and JS-enabled headless driver for any app (requires node.js)"
+                "behat/mink-zombie-driver": "fast and JS-enabled headless driver for any app (requires node.js)",
+                "dmore/chrome-mink-driver": "fast and JS-enabled driver for any app (requires chromium or google chrome)"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.7.x-dev"
+                    "dev-master": "1.8.x-dev"
                 }
             },
             "autoload": {
@@ -202,31 +205,32 @@
                 "testing",
                 "web"
             ],
-            "time": "2016-03-05T08:26:18+00:00"
+            "time": "2020-03-11T15:45:53+00:00"
         },
         {
             "name": "behat/mink-browserkit-driver",
-            "version": "v1.3.2",
+            "version": "v1.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/minkphp/MinkBrowserKitDriver.git",
-                "reference": "10e67fb4a295efcd62ea0bf16025a85ea19534fb"
+                "reference": "e3b90840022ebcd544c7b394a3c9597ae242cbee"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/minkphp/MinkBrowserKitDriver/zipball/10e67fb4a295efcd62ea0bf16025a85ea19534fb",
-                "reference": "10e67fb4a295efcd62ea0bf16025a85ea19534fb",
+                "url": "https://api.github.com/repos/minkphp/MinkBrowserKitDriver/zipball/e3b90840022ebcd544c7b394a3c9597ae242cbee",
+                "reference": "e3b90840022ebcd544c7b394a3c9597ae242cbee",
                 "shasum": ""
             },
             "require": {
                 "behat/mink": "^1.7.1@dev",
                 "php": ">=5.3.6",
-                "symfony/browser-kit": "~2.3|~3.0",
-                "symfony/dom-crawler": "~2.3|~3.0"
+                "symfony/browser-kit": "~2.3|~3.0|~4.0",
+                "symfony/dom-crawler": "~2.3|~3.0|~4.0"
             },
             "require-dev": {
-                "silex/silex": "~1.2",
-                "symfony/phpunit-bridge": "~2.7|~3.0"
+                "mink/driver-testsuite": "dev-master",
+                "symfony/debug": "^2.7|^3.0|^4.0",
+                "symfony/http-kernel": "~2.3|~3.0|~4.0"
             },
             "type": "mink-driver",
             "extra": {
@@ -258,20 +262,20 @@
                 "browser",
                 "testing"
             ],
-            "time": "2016-03-05T08:59:47+00:00"
+            "time": "2020-03-11T09:49:45+00:00"
         },
         {
             "name": "behat/mink-extension",
-            "version": "2.3.0",
+            "version": "2.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Behat/MinkExtension.git",
-                "reference": "badc565b7a1d05c4a4bf49c789045bcf7af6c6de"
+                "reference": "80f7849ba53867181b7e412df9210e12fba50177"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Behat/MinkExtension/zipball/badc565b7a1d05c4a4bf49c789045bcf7af6c6de",
-                "reference": "badc565b7a1d05c4a4bf49c789045bcf7af6c6de",
+                "url": "https://api.github.com/repos/Behat/MinkExtension/zipball/80f7849ba53867181b7e412df9210e12fba50177",
+                "reference": "80f7849ba53867181b7e412df9210e12fba50177",
                 "shasum": ""
             },
             "require": {
@@ -317,7 +321,7 @@
                 "test",
                 "web"
             ],
-            "time": "2017-11-24T19:30:49+00:00"
+            "time": "2018-02-06T15:36:30+00:00"
         },
         {
             "name": "behat/mink-goutte-driver",
@@ -376,30 +380,30 @@
         },
         {
             "name": "behat/mink-selenium2-driver",
-            "version": "v1.3.1",
+            "version": "v1.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/minkphp/MinkSelenium2Driver.git",
-                "reference": "473a9f3ebe0c134ee1e623ce8a9c852832020288"
+                "reference": "312a967dd527f28980cce40850339cd5316da092"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/minkphp/MinkSelenium2Driver/zipball/473a9f3ebe0c134ee1e623ce8a9c852832020288",
-                "reference": "473a9f3ebe0c134ee1e623ce8a9c852832020288",
+                "url": "https://api.github.com/repos/minkphp/MinkSelenium2Driver/zipball/312a967dd527f28980cce40850339cd5316da092",
+                "reference": "312a967dd527f28980cce40850339cd5316da092",
                 "shasum": ""
             },
             "require": {
                 "behat/mink": "~1.7@dev",
                 "instaclick/php-webdriver": "~1.1",
-                "php": ">=5.3.1"
+                "php": ">=5.4"
             },
             "require-dev": {
-                "symfony/phpunit-bridge": "~2.7"
+                "mink/driver-testsuite": "dev-master"
             },
             "type": "mink-driver",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.3.x-dev"
+                    "dev-master": "1.4.x-dev"
                 }
             },
             "autoload": {
@@ -412,15 +416,15 @@
                 "MIT"
             ],
             "authors": [
-                {
-                    "name": "Konstantin Kudryashov",
-                    "email": "ever.zet@gmail.com",
-                    "homepage": "http://everzet.com"
-                },
                 {
                     "name": "Pete Otaqui",
                     "email": "pete@otaqui.com",
                     "homepage": "https://github.com/pete-otaqui"
+                },
+                {
+                    "name": "Konstantin Kudryashov",
+                    "email": "ever.zet@gmail.com",
+                    "homepage": "http://everzet.com"
                 }
             ],
             "description": "Selenium2 (WebDriver) driver for Mink framework",
@@ -433,20 +437,20 @@
                 "testing",
                 "webdriver"
             ],
-            "time": "2016-03-05T09:10:18+00:00"
+            "time": "2020-03-11T14:43:21+00:00"
         },
         {
             "name": "behat/transliterator",
-            "version": "v1.2.0",
+            "version": "v1.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Behat/Transliterator.git",
-                "reference": "826ce7e9c2a6664c0d1f381cbb38b1fb80a7ee2c"
+                "reference": "3c4ec1d77c3d05caa1f0bf8fb3aae4845005c7fc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Behat/Transliterator/zipball/826ce7e9c2a6664c0d1f381cbb38b1fb80a7ee2c",
-                "reference": "826ce7e9c2a6664c0d1f381cbb38b1fb80a7ee2c",
+                "url": "https://api.github.com/repos/Behat/Transliterator/zipball/3c4ec1d77c3d05caa1f0bf8fb3aae4845005c7fc",
+                "reference": "3c4ec1d77c3d05caa1f0bf8fb3aae4845005c7fc",
                 "shasum": ""
             },
             "require": {
@@ -454,7 +458,8 @@
             },
             "require-dev": {
                 "chuyskywalker/rolling-curl": "^3.1",
-                "php-yaoi/php-yaoi": "^1.0"
+                "php-yaoi/php-yaoi": "^1.0",
+                "phpunit/phpunit": "^4.8.36|^6.3"
             },
             "type": "library",
             "extra": {
@@ -463,8 +468,8 @@
                 }
             },
             "autoload": {
-                "psr-0": {
-                    "Behat\\Transliterator": "src/"
+                "psr-4": {
+                    "Behat\\Transliterator\\": "src/Behat/Transliterator"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
@@ -477,7 +482,7 @@
                 "slug",
                 "transliterator"
             ],
-            "time": "2017-04-04T11:38:05+00:00"
+            "time": "2020-01-14T16:39:13+00:00"
         },
         {
             "name": "doctrine/instantiator",
@@ -535,35 +540,41 @@
         },
         {
             "name": "fabpot/goutte",
-            "version": "v2.0.4",
+            "version": "v3.2.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/FriendsOfPHP/Goutte.git",
-                "reference": "0ad3ee6dc2d0aaa832a80041a1e09bf394e99802"
+                "reference": "3f0eaf0a40181359470651f1565b3e07e3dd31b8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/FriendsOfPHP/Goutte/zipball/0ad3ee6dc2d0aaa832a80041a1e09bf394e99802",
-                "reference": "0ad3ee6dc2d0aaa832a80041a1e09bf394e99802",
+                "url": "https://api.github.com/repos/FriendsOfPHP/Goutte/zipball/3f0eaf0a40181359470651f1565b3e07e3dd31b8",
+                "reference": "3f0eaf0a40181359470651f1565b3e07e3dd31b8",
                 "shasum": ""
             },
             "require": {
-                "guzzlehttp/guzzle": ">=4,<6",
-                "php": ">=5.4.0",
-                "symfony/browser-kit": "~2.1",
-                "symfony/css-selector": "~2.1",
-                "symfony/dom-crawler": "~2.1"
+                "guzzlehttp/guzzle": "^6.0",
+                "php": ">=5.5.0",
+                "symfony/browser-kit": "~2.1|~3.0|~4.0",
+                "symfony/css-selector": "~2.1|~3.0|~4.0",
+                "symfony/dom-crawler": "~2.1|~3.0|~4.0"
+            },
+            "require-dev": {
+                "symfony/phpunit-bridge": "^3.3 || ^4"
             },
             "type": "application",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.0-dev"
+                    "dev-master": "3.2-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
                     "Goutte\\": "Goutte"
-                }
+                },
+                "exclude-from-classmap": [
+                    "Goutte/Tests"
+                ]
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -580,36 +591,50 @@
             "keywords": [
                 "scraper"
             ],
-            "time": "2015-05-05T21:14:57+00:00"
+            "time": "2018-06-29T15:13:57+00:00"
         },
         {
             "name": "guzzlehttp/guzzle",
-            "version": "5.3.2",
+            "version": "6.5.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/guzzle.git",
-                "reference": "f9acb4761844317e626a32259205bec1f1bc60d2"
+                "reference": "aab4ebd862aa7d04f01a4b51849d657db56d882e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/f9acb4761844317e626a32259205bec1f1bc60d2",
-                "reference": "f9acb4761844317e626a32259205bec1f1bc60d2",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aab4ebd862aa7d04f01a4b51849d657db56d882e",
+                "reference": "aab4ebd862aa7d04f01a4b51849d657db56d882e",
                 "shasum": ""
             },
             "require": {
-                "guzzlehttp/ringphp": "^1.1",
-                "php": ">=5.4.0",
-                "react/promise": "^2.2"
+                "ext-json": "*",
+                "guzzlehttp/promises": "^1.0",
+                "guzzlehttp/psr7": "^1.6.1",
+                "php": ">=5.5",
+                "symfony/polyfill-intl-idn": "^1.11"
             },
             "require-dev": {
                 "ext-curl": "*",
-                "phpunit/phpunit": "^4.0"
+                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0",
+                "psr/log": "^1.1"
+            },
+            "suggest": {
+                "psr/log": "Required for using the Log middleware"
             },
             "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "6.5-dev"
+                }
+            },
             "autoload": {
                 "psr-4": {
                     "GuzzleHttp\\": "src/"
-                }
+                },
+                "files": [
+                    "src/functions_include.php"
+                ]
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -622,7 +647,7 @@
                     "homepage": "https://github.com/mtdowling"
                 }
             ],
-            "description": "Guzzle is a PHP HTTP client library and framework for building RESTful web service clients",
+            "description": "Guzzle is a PHP HTTP client library",
             "homepage": "http://guzzlephp.org/",
             "keywords": [
                 "client",
@@ -633,44 +658,41 @@
                 "rest",
                 "web service"
             ],
-            "time": "2018-01-15T07:18:01+00:00"
+            "time": "2020-04-18T10:38:46+00:00"
         },
         {
-            "name": "guzzlehttp/ringphp",
-            "version": "1.1.0",
+            "name": "guzzlehttp/promises",
+            "version": "v1.3.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/guzzle/RingPHP.git",
-                "reference": "dbbb91d7f6c191e5e405e900e3102ac7f261bc0b"
+                "url": "https://github.com/guzzle/promises.git",
+                "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/RingPHP/zipball/dbbb91d7f6c191e5e405e900e3102ac7f261bc0b",
-                "reference": "dbbb91d7f6c191e5e405e900e3102ac7f261bc0b",
+                "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646",
+                "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646",
                 "shasum": ""
             },
             "require": {
-                "guzzlehttp/streams": "~3.0",
-                "php": ">=5.4.0",
-                "react/promise": "~2.0"
+                "php": ">=5.5.0"
             },
             "require-dev": {
-                "ext-curl": "*",
-                "phpunit/phpunit": "~4.0"
-            },
-            "suggest": {
-                "ext-curl": "Guzzle will use specific adapters if cURL is present"
+                "phpunit/phpunit": "^4.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.1-dev"
+                    "dev-master": "1.4-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "GuzzleHttp\\Ring\\": "src/"
-                }
+                    "GuzzleHttp\\Promise\\": "src/"
+                },
+                "files": [
+                    "src/functions_include.php"
+                ]
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -683,39 +705,54 @@
                     "homepage": "https://github.com/mtdowling"
                 }
             ],
-            "description": "Provides a simple API and specification that abstracts away the details of HTTP into a single PHP function.",
-            "time": "2015-05-20T03:37:09+00:00"
+            "description": "Guzzle promises library",
+            "keywords": [
+                "promise"
+            ],
+            "time": "2016-12-20T10:07:11+00:00"
         },
         {
-            "name": "guzzlehttp/streams",
-            "version": "3.0.0",
+            "name": "guzzlehttp/psr7",
+            "version": "1.6.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/guzzle/streams.git",
-                "reference": "47aaa48e27dae43d39fc1cea0ccf0d84ac1a2ba5"
+                "url": "https://github.com/guzzle/psr7.git",
+                "reference": "239400de7a173fe9901b9ac7c06497751f00727a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/streams/zipball/47aaa48e27dae43d39fc1cea0ccf0d84ac1a2ba5",
-                "reference": "47aaa48e27dae43d39fc1cea0ccf0d84ac1a2ba5",
+                "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a",
+                "reference": "239400de7a173fe9901b9ac7c06497751f00727a",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.4.0"
+                "php": ">=5.4.0",
+                "psr/http-message": "~1.0",
+                "ralouphie/getallheaders": "^2.0.5 || ^3.0.0"
+            },
+            "provide": {
+                "psr/http-message-implementation": "1.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "~4.0"
+                "ext-zlib": "*",
+                "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8"
+            },
+            "suggest": {
+                "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.0-dev"
+                    "dev-master": "1.6-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "GuzzleHttp\\Stream\\": "src/"
-                }
+                    "GuzzleHttp\\Psr7\\": "src/"
+                },
+                "files": [
+                    "src/functions_include.php"
+                ]
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -726,28 +763,37 @@
                     "name": "Michael Dowling",
                     "email": "mtdowling@gmail.com",
                     "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "Tobias Schultze",
+                    "homepage": "https://github.com/Tobion"
                 }
             ],
-            "description": "Provides a simple abstraction over streams of data",
-            "homepage": "http://guzzlephp.org/",
+            "description": "PSR-7 message implementation that also provides common utility methods",
             "keywords": [
-                "Guzzle",
-                "stream"
-            ],
-            "time": "2014-10-12T19:18:40+00:00"
+                "http",
+                "message",
+                "psr-7",
+                "request",
+                "response",
+                "stream",
+                "uri",
+                "url"
+            ],
+            "time": "2019-07-01T23:21:34+00:00"
         },
         {
             "name": "instaclick/php-webdriver",
-            "version": "1.4.5",
+            "version": "1.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/instaclick/php-webdriver.git",
-                "reference": "6fa959452e774dcaed543faad3a9d1a37d803327"
+                "reference": "b5f330e900e9b3edfc18024a5ec8c07136075712"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/instaclick/php-webdriver/zipball/6fa959452e774dcaed543faad3a9d1a37d803327",
-                "reference": "6fa959452e774dcaed543faad3a9d1a37d803327",
+                "url": "https://api.github.com/repos/instaclick/php-webdriver/zipball/b5f330e900e9b3edfc18024a5ec8c07136075712",
+                "reference": "b5f330e900e9b3edfc18024a5ec8c07136075712",
                 "shasum": ""
             },
             "require": {
@@ -793,7 +839,53 @@
                 "webdriver",
                 "webtest"
             ],
-            "time": "2017-06-30T04:02:48+00:00"
+            "time": "2019-09-25T09:05:11+00:00"
+        },
+        {
+            "name": "mikey179/vfsstream",
+            "version": "v1.6.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/bovigo/vfsStream.git",
+                "reference": "231c73783ebb7dd9ec77916c10037eff5a2b6efe"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/231c73783ebb7dd9ec77916c10037eff5a2b6efe",
+                "reference": "231c73783ebb7dd9ec77916c10037eff5a2b6efe",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.5|^5.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.6.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-0": {
+                    "org\\bovigo\\vfs\\": "src/main/php"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Frank Kleine",
+                    "homepage": "http://frankkleine.de/",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Virtual file system to mock the real file system in unit tests.",
+            "homepage": "http://vfs.bovigo.org/",
+            "time": "2019-10-30T15:31:00+00:00"
         },
         {
             "name": "moodlehq/behat-extension",
@@ -844,39 +936,136 @@
             ],
             "time": "2018-01-24T19:13:01+00:00"
         },
+        {
+            "name": "phpdocumentor/reflection-common",
+            "version": "1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+                "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6",
+                "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.5"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.6"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "phpDocumentor\\Reflection\\": [
+                        "src"
+                    ]
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jaap van Otterdijk",
+                    "email": "opensource@ijaap.nl"
+                }
+            ],
+            "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+            "homepage": "http://www.phpdoc.org",
+            "keywords": [
+                "FQSEN",
+                "phpDocumentor",
+                "phpdoc",
+                "reflection",
+                "static analysis"
+            ],
+            "time": "2017-09-11T18:02:19+00:00"
+        },
         {
             "name": "phpdocumentor/reflection-docblock",
-            "version": "2.0.5",
+            "version": "3.2.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
-                "reference": "e6a969a640b00d8daa3c66518b0405fb41ae0c4b"
+                "reference": "4aada1f93c72c35e22fb1383b47fee43b8f1d157"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e6a969a640b00d8daa3c66518b0405fb41ae0c4b",
-                "reference": "e6a969a640b00d8daa3c66518b0405fb41ae0c4b",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/4aada1f93c72c35e22fb1383b47fee43b8f1d157",
+                "reference": "4aada1f93c72c35e22fb1383b47fee43b8f1d157",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": ">=5.5",
+                "phpdocumentor/reflection-common": "^1.0@dev",
+                "phpdocumentor/type-resolver": "^0.3.0",
+                "webmozart/assert": "^1.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "~4.0"
+                "mockery/mockery": "^0.9.4",
+                "phpunit/phpunit": "^4.4"
             },
-            "suggest": {
-                "dflydev/markdown": "~1.0",
-                "erusev/parsedown": "~1.0"
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "phpDocumentor\\Reflection\\": [
+                        "src/"
+                    ]
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Mike van Riel",
+                    "email": "me@mikevanriel.com"
+                }
+            ],
+            "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+            "time": "2017-08-08T06:39:58+00:00"
+        },
+        {
+            "name": "phpdocumentor/type-resolver",
+            "version": "0.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpDocumentor/TypeResolver.git",
+                "reference": "fb3933512008d8162b3cdf9e18dba9309b7c3773"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/fb3933512008d8162b3cdf9e18dba9309b7c3773",
+                "reference": "fb3933512008d8162b3cdf9e18dba9309b7c3773",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5 || ^7.0",
+                "phpdocumentor/reflection-common": "^1.0"
+            },
+            "require-dev": {
+                "mockery/mockery": "^0.9.4",
+                "phpunit/phpunit": "^5.2||^4.8.24"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.0.x-dev"
+                    "dev-master": "1.0.x-dev"
                 }
             },
             "autoload": {
-                "psr-0": {
-                    "phpDocumentor": [
+                "psr-4": {
+                    "phpDocumentor\\Reflection\\": [
                         "src/"
                     ]
                 }
@@ -888,45 +1077,45 @@
             "authors": [
                 {
                     "name": "Mike van Riel",
-                    "email": "mike.vanriel@naenius.com"
+                    "email": "me@mikevanriel.com"
                 }
             ],
-            "time": "2016-01-25T08:17:30+00:00"
+            "time": "2017-06-03T08:32:36+00:00"
         },
         {
             "name": "phpspec/prophecy",
-            "version": "1.7.3",
+            "version": "v1.10.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "e4ed002c67da8eceb0eb8ddb8b3847bb53c5c2bf"
+                "reference": "451c3cd1418cf640de218914901e51b064abb093"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/e4ed002c67da8eceb0eb8ddb8b3847bb53c5c2bf",
-                "reference": "e4ed002c67da8eceb0eb8ddb8b3847bb53c5c2bf",
+                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093",
+                "reference": "451c3cd1418cf640de218914901e51b064abb093",
                 "shasum": ""
             },
             "require": {
                 "doctrine/instantiator": "^1.0.2",
                 "php": "^5.3|^7.0",
-                "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0",
-                "sebastian/comparator": "^1.1|^2.0",
-                "sebastian/recursion-context": "^1.0|^2.0|^3.0"
+                "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0",
+                "sebastian/comparator": "^1.2.3|^2.0|^3.0|^4.0",
+                "sebastian/recursion-context": "^1.0|^2.0|^3.0|^4.0"
             },
             "require-dev": {
-                "phpspec/phpspec": "^2.5|^3.2",
-                "phpunit/phpunit": "^4.8.35 || ^5.7"
+                "phpspec/phpspec": "^2.5 || ^3.2",
+                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.7.x-dev"
+                    "dev-master": "1.10.x-dev"
                 }
             },
             "autoload": {
-                "psr-0": {
-                    "Prophecy\\": "src/"
+                "psr-4": {
+                    "Prophecy\\": "src/Prophecy"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
@@ -954,7 +1143,7 @@
                 "spy",
                 "stub"
             ],
-            "time": "2017-11-24T13:59:53+00:00"
+            "time": "2020-03-05T15:02:03+00:00"
         },
         {
             "name": "phpunit/dbunit",
@@ -1391,18 +1580,117 @@
             ],
             "time": "2015-10-02T06:51:40+00:00"
         },
+        {
+            "name": "psr/container",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/container.git",
+                "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
+                "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Container\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Common Container Interface (PHP FIG PSR-11)",
+            "homepage": "https://github.com/php-fig/container",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interface",
+                "container-interop",
+                "psr"
+            ],
+            "time": "2017-02-14T16:28:37+00:00"
+        },
+        {
+            "name": "psr/http-message",
+            "version": "1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-message.git",
+                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363",
+                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Message\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP messages",
+            "homepage": "https://github.com/php-fig/http-message",
+            "keywords": [
+                "http",
+                "http-message",
+                "psr",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "time": "2016-08-06T14:39:51+00:00"
+        },
         {
             "name": "psr/log",
-            "version": "1.0.2",
+            "version": "1.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/log.git",
-                "reference": "4ebe3a8bf773a19edfe0a84b6585ba3d401b724d"
+                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/4ebe3a8bf773a19edfe0a84b6585ba3d401b724d",
-                "reference": "4ebe3a8bf773a19edfe0a84b6585ba3d401b724d",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc",
+                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc",
                 "shasum": ""
             },
             "require": {
@@ -1411,7 +1699,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.0.x-dev"
+                    "dev-master": "1.1.x-dev"
                 }
             },
             "autoload": {
@@ -1436,35 +1724,33 @@
                 "psr",
                 "psr-3"
             ],
-            "time": "2016-10-10T12:19:37+00:00"
+            "time": "2020-03-23T09:12:05+00:00"
         },
         {
-            "name": "react/promise",
-            "version": "v2.5.1",
+            "name": "ralouphie/getallheaders",
+            "version": "2.0.5",
             "source": {
                 "type": "git",
-                "url": "https://github.com/reactphp/promise.git",
-                "reference": "62785ae604c8d69725d693eb370e1d67e94c4053"
+                "url": "https://github.com/ralouphie/getallheaders.git",
+                "reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/reactphp/promise/zipball/62785ae604c8d69725d693eb370e1d67e94c4053",
-                "reference": "62785ae604c8d69725d693eb370e1d67e94c4053",
+                "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/5601c8a83fbba7ef674a7369456d12f1e0d0eafa",
+                "reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.4.0"
+                "php": ">=5.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "~4.8"
+                "phpunit/phpunit": "~3.7.0",
+                "satooshi/php-coveralls": ">=1.0"
             },
             "type": "library",
             "autoload": {
-                "psr-4": {
-                    "React\\Promise\\": "src/"
-                },
                 "files": [
-                    "src/functions_include.php"
+                    "src/getallheaders.php"
                 ]
             },
             "notification-url": "https://packagist.org/downloads/",
@@ -1473,16 +1759,12 @@
             ],
             "authors": [
                 {
-                    "name": "Jan Sorgalla",
-                    "email": "jsorgalla@gmail.com"
+                    "name": "Ralph Khattar",
+                    "email": "ralph.khattar@gmail.com"
                 }
             ],
-            "description": "A lightweight implementation of CommonJS Promises/A for PHP",
-            "keywords": [
-                "promise",
-                "promises"
-            ],
-            "time": "2017-03-25T12:08:31+00:00"
+            "description": "A polyfill for getallheaders.",
+            "time": "2016-02-11T07:05:27+00:00"
         },
         {
             "name": "sebastian/comparator",
@@ -1858,25 +2140,25 @@
         },
         {
             "name": "symfony/browser-kit",
-            "version": "v2.8.33",
+            "version": "v3.4.40",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/browser-kit.git",
-                "reference": "e49a78bcf09ba2e6d03e63e80211f889c037add5"
+                "reference": "1c7bcd954ad1fc02354c4cfd3fcd1b0c95245367"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/browser-kit/zipball/e49a78bcf09ba2e6d03e63e80211f889c037add5",
-                "reference": "e49a78bcf09ba2e6d03e63e80211f889c037add5",
+                "url": "https://api.github.com/repos/symfony/browser-kit/zipball/1c7bcd954ad1fc02354c4cfd3fcd1b0c95245367",
+                "reference": "1c7bcd954ad1fc02354c4cfd3fcd1b0c95245367",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9",
-                "symfony/dom-crawler": "~2.1|~3.0.0"
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/dom-crawler": "~2.8|~3.0|~4.0"
             },
             "require-dev": {
-                "symfony/css-selector": "^2.0.5|~3.0.0",
-                "symfony/process": "~2.3.34|^2.7.6|~3.0.0"
+                "symfony/css-selector": "~2.8|~3.0|~4.0",
+                "symfony/process": "~2.8|~3.0|~4.0"
             },
             "suggest": {
                 "symfony/process": ""
@@ -1884,7 +2166,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
@@ -1911,33 +2193,36 @@
             ],
             "description": "Symfony BrowserKit Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2020-03-15T09:38:08+00:00"
         },
         {
             "name": "symfony/class-loader",
-            "version": "v2.8.33",
+            "version": "v3.4.40",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/class-loader.git",
-                "reference": "f87f46e5e1bf31382a65966795fa2d4dd7e2b300"
+                "reference": "e4636a4f23f157278a19e5db160c63de0da297d8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/class-loader/zipball/f87f46e5e1bf31382a65966795fa2d4dd7e2b300",
-                "reference": "f87f46e5e1bf31382a65966795fa2d4dd7e2b300",
+                "url": "https://api.github.com/repos/symfony/class-loader/zipball/e4636a4f23f157278a19e5db160c63de0da297d8",
+                "reference": "e4636a4f23f157278a19e5db160c63de0da297d8",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9",
-                "symfony/polyfill-apcu": "~1.1"
+                "php": "^5.5.9|>=7.0.8"
             },
             "require-dev": {
-                "symfony/finder": "^2.0.5|~3.0.0"
+                "symfony/finder": "~2.8|~3.0|~4.0",
+                "symfony/polyfill-apcu": "~1.1"
+            },
+            "suggest": {
+                "symfony/polyfill-apcu": "For using ApcClassLoader on HHVM"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
@@ -1964,28 +2249,36 @@
             ],
             "description": "Symfony ClassLoader Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2020-03-15T09:38:08+00:00"
         },
         {
             "name": "symfony/config",
-            "version": "v2.8.33",
+            "version": "v3.4.40",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/config.git",
-                "reference": "705d1687222c08deabac8993ec2e04ad1a422c52"
+                "reference": "3634991bea549e73c45a964c38f30ceeae6ed877"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/config/zipball/705d1687222c08deabac8993ec2e04ad1a422c52",
-                "reference": "705d1687222c08deabac8993ec2e04ad1a422c52",
+                "url": "https://api.github.com/repos/symfony/config/zipball/3634991bea549e73c45a964c38f30ceeae6ed877",
+                "reference": "3634991bea549e73c45a964c38f30ceeae6ed877",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9",
-                "symfony/filesystem": "~2.3|~3.0.0"
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/filesystem": "~2.8|~3.0|~4.0",
+                "symfony/polyfill-ctype": "~1.8"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<3.3",
+                "symfony/finder": "<3.3"
             },
             "require-dev": {
-                "symfony/yaml": "~2.7|~3.0.0"
+                "symfony/dependency-injection": "~3.3|~4.0",
+                "symfony/event-dispatcher": "~3.3|~4.0",
+                "symfony/finder": "~3.3|~4.0",
+                "symfony/yaml": "~3.0|~4.0"
             },
             "suggest": {
                 "symfony/yaml": "To use the yaml reference dumper"
@@ -1993,7 +2286,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
@@ -2020,41 +2313,48 @@
             ],
             "description": "Symfony Config Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2020-04-12T14:33:46+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v2.8.33",
+            "version": "v3.3.18",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "a4bd0f02ea156cf7b5138774a7ba0ab44d8da4fe"
+                "reference": "af7ec995de93671c03cc1b4e3176c8588bc79dcc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/a4bd0f02ea156cf7b5138774a7ba0ab44d8da4fe",
-                "reference": "a4bd0f02ea156cf7b5138774a7ba0ab44d8da4fe",
+                "url": "https://api.github.com/repos/symfony/console/zipball/af7ec995de93671c03cc1b4e3176c8588bc79dcc",
+                "reference": "af7ec995de93671c03cc1b4e3176c8588bc79dcc",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9",
-                "symfony/debug": "^2.7.2|~3.0.0",
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/debug": "~2.8|~3.0",
                 "symfony/polyfill-mbstring": "~1.0"
             },
+            "conflict": {
+                "symfony/dependency-injection": "<3.3"
+            },
             "require-dev": {
                 "psr/log": "~1.0",
-                "symfony/event-dispatcher": "~2.1|~3.0.0",
-                "symfony/process": "~2.1|~3.0.0"
+                "symfony/config": "~3.3",
+                "symfony/dependency-injection": "~3.3",
+                "symfony/event-dispatcher": "~2.8|~3.0",
+                "symfony/filesystem": "~2.8|~3.0",
+                "symfony/process": "~2.8|~3.0"
             },
             "suggest": {
                 "psr/log": "For using the console logger",
                 "symfony/event-dispatcher": "",
+                "symfony/filesystem": "",
                 "symfony/process": ""
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.3-dev"
                 }
             },
             "autoload": {
@@ -2081,29 +2381,29 @@
             ],
             "description": "Symfony Console Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2018-01-29T09:02:23+00:00"
         },
         {
             "name": "symfony/css-selector",
-            "version": "v2.8.33",
+            "version": "v3.4.40",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/css-selector.git",
-                "reference": "c5b39674eacd34adedbef78227c57109caa9e318"
+                "reference": "9ccf6e78077a3fc1596e6c7b5958008965a11518"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/css-selector/zipball/c5b39674eacd34adedbef78227c57109caa9e318",
-                "reference": "c5b39674eacd34adedbef78227c57109caa9e318",
+                "url": "https://api.github.com/repos/symfony/css-selector/zipball/9ccf6e78077a3fc1596e6c7b5958008965a11518",
+                "reference": "9ccf6e78077a3fc1596e6c7b5958008965a11518",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9"
+                "php": "^5.5.9|>=7.0.8"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
@@ -2119,14 +2419,14 @@
                 "MIT"
             ],
             "authors": [
-                {
-                    "name": "Jean-François Simon",
-                    "email": "jeanfrancois.simon@sensiolabs.com"
-                },
                 {
                     "name": "Fabien Potencier",
                     "email": "fabien@symfony.com"
                 },
+                {
+                    "name": "Jean-François Simon",
+                    "email": "jeanfrancois.simon@sensiolabs.com"
+                },
                 {
                     "name": "Symfony Community",
                     "homepage": "https://symfony.com/contributors"
@@ -2134,37 +2434,36 @@
             ],
             "description": "Symfony CssSelector Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2020-03-16T08:31:04+00:00"
         },
         {
             "name": "symfony/debug",
-            "version": "v2.8.33",
+            "version": "v3.4.40",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/debug.git",
-                "reference": "546db6f2bf8aefb43f37ce6f2f67fb700c51c1c2"
+                "reference": "ce9f3b5e8e1c50f849fded59b3a1b6bc3562ec29"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/debug/zipball/546db6f2bf8aefb43f37ce6f2f67fb700c51c1c2",
-                "reference": "546db6f2bf8aefb43f37ce6f2f67fb700c51c1c2",
+                "url": "https://api.github.com/repos/symfony/debug/zipball/ce9f3b5e8e1c50f849fded59b3a1b6bc3562ec29",
+                "reference": "ce9f3b5e8e1c50f849fded59b3a1b6bc3562ec29",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9",
+                "php": "^5.5.9|>=7.0.8",
                 "psr/log": "~1.0"
             },
             "conflict": {
                 "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2"
             },
             "require-dev": {
-                "symfony/class-loader": "~2.2|~3.0.0",
-                "symfony/http-kernel": "~2.3.24|~2.5.9|^2.6.2|~3.0.0"
+                "symfony/http-kernel": "~2.8|~3.0|~4.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
@@ -2191,43 +2490,50 @@
             ],
             "description": "Symfony Debug Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T17:12:09+00:00"
+            "time": "2020-03-23T10:22:40+00:00"
         },
         {
             "name": "symfony/dependency-injection",
-            "version": "v2.8.33",
+            "version": "v3.3.18",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dependency-injection.git",
-                "reference": "954ff7c61879479b2f57f427798f9c82555438a6"
+                "reference": "54243abc4e1a1a15e274e391bd6f7090b44711f1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/954ff7c61879479b2f57f427798f9c82555438a6",
-                "reference": "954ff7c61879479b2f57f427798f9c82555438a6",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/54243abc4e1a1a15e274e391bd6f7090b44711f1",
+                "reference": "54243abc4e1a1a15e274e391bd6f7090b44711f1",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9"
+                "php": "^5.5.9|>=7.0.8",
+                "psr/container": "^1.0"
             },
             "conflict": {
-                "symfony/expression-language": "<2.6"
+                "symfony/config": "<3.3.7",
+                "symfony/finder": "<3.3",
+                "symfony/yaml": "<3.3"
+            },
+            "provide": {
+                "psr/container-implementation": "1.0"
             },
             "require-dev": {
-                "symfony/config": "~2.2|~3.0.0",
-                "symfony/expression-language": "~2.6|~3.0.0",
-                "symfony/yaml": "~2.3.42|~2.7.14|~2.8.7|~3.0.7"
+                "symfony/config": "~3.3",
+                "symfony/expression-language": "~2.8|~3.0",
+                "symfony/yaml": "~3.3"
             },
             "suggest": {
                 "symfony/config": "",
                 "symfony/expression-language": "For using expressions in service container configuration",
+                "symfony/finder": "For using double-star glob patterns or when GLOB_BRACE portability is required",
                 "symfony/proxy-manager-bridge": "Generate service proxies to lazy load them",
                 "symfony/yaml": ""
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.3-dev"
                 }
             },
             "autoload": {
@@ -2254,28 +2560,29 @@
             ],
             "description": "Symfony DependencyInjection Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2018-01-29T09:02:23+00:00"
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v2.8.33",
+            "version": "v3.4.40",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "31ff8f1d7a3de4b43b35ff821e6e223d81a8988b"
+                "reference": "ceacdab4abf7695ef6bec77c8b7983e1544c6358"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/31ff8f1d7a3de4b43b35ff821e6e223d81a8988b",
-                "reference": "31ff8f1d7a3de4b43b35ff821e6e223d81a8988b",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/ceacdab4abf7695ef6bec77c8b7983e1544c6358",
+                "reference": "ceacdab4abf7695ef6bec77c8b7983e1544c6358",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9",
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/polyfill-ctype": "~1.8",
                 "symfony/polyfill-mbstring": "~1.0"
             },
             "require-dev": {
-                "symfony/css-selector": "~2.8|~3.0.0"
+                "symfony/css-selector": "~2.8|~3.0|~4.0"
             },
             "suggest": {
                 "symfony/css-selector": ""
@@ -2283,7 +2590,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
@@ -2310,31 +2617,34 @@
             ],
             "description": "Symfony DomCrawler Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2020-03-16T08:31:04+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v2.8.33",
+            "version": "v3.4.40",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "d64be24fc1eba62f9daace8a8918f797fc8e87cc"
+                "reference": "9d4e22943b73acc1ba50595b7de1a01fe9dbad48"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d64be24fc1eba62f9daace8a8918f797fc8e87cc",
-                "reference": "d64be24fc1eba62f9daace8a8918f797fc8e87cc",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9d4e22943b73acc1ba50595b7de1a01fe9dbad48",
+                "reference": "9d4e22943b73acc1ba50595b7de1a01fe9dbad48",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9"
+                "php": "^5.5.9|>=7.0.8"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<3.3"
             },
             "require-dev": {
                 "psr/log": "~1.0",
-                "symfony/config": "^2.0.5|~3.0.0",
-                "symfony/dependency-injection": "~2.6|~3.0.0",
-                "symfony/expression-language": "~2.6|~3.0.0",
-                "symfony/stopwatch": "~2.3|~3.0.0"
+                "symfony/config": "~2.8|~3.0|~4.0",
+                "symfony/dependency-injection": "~3.3|~4.0",
+                "symfony/expression-language": "~2.8|~3.0|~4.0",
+                "symfony/stopwatch": "~2.8|~3.0|~4.0"
             },
             "suggest": {
                 "symfony/dependency-injection": "",
@@ -2343,7 +2653,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
@@ -2370,29 +2680,30 @@
             ],
             "description": "Symfony EventDispatcher Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2020-03-15T09:38:08+00:00"
         },
         {
             "name": "symfony/filesystem",
-            "version": "v2.8.33",
+            "version": "v3.4.40",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "1f4e8351e0196562f5e8ec584baeceeb8e2e92f6"
+                "reference": "78a93e5606a19d0fb490afc3c4a9b7ecd86e1515"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/1f4e8351e0196562f5e8ec584baeceeb8e2e92f6",
-                "reference": "1f4e8351e0196562f5e8ec584baeceeb8e2e92f6",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/78a93e5606a19d0fb490afc3c4a9b7ecd86e1515",
+                "reference": "78a93e5606a19d0fb490afc3c4a9b7ecd86e1515",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9"
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/polyfill-ctype": "~1.8"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
@@ -2419,34 +2730,37 @@
             ],
             "description": "Symfony Filesystem Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2020-04-12T16:54:01+00:00"
         },
         {
-            "name": "symfony/polyfill-apcu",
-            "version": "v1.6.0",
+            "name": "symfony/polyfill-ctype",
+            "version": "v1.15.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/polyfill-apcu.git",
-                "reference": "04f62674339602def515bff4bc6901fc1d4951e8"
+                "url": "https://github.com/symfony/polyfill-ctype.git",
+                "reference": "4719fa9c18b0464d399f1a63bf624b42b6fa8d14"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-apcu/zipball/04f62674339602def515bff4bc6901fc1d4951e8",
-                "reference": "04f62674339602def515bff4bc6901fc1d4951e8",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/4719fa9c18b0464d399f1a63bf624b42b6fa8d14",
+                "reference": "4719fa9c18b0464d399f1a63bf624b42b6fa8d14",
                 "shasum": ""
             },
             "require": {
                 "php": ">=5.3.3"
             },
+            "suggest": {
+                "ext-ctype": "For best performance"
+            },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.6-dev"
+                    "dev-master": "1.15-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Polyfill\\Apcu\\": ""
+                    "Symfony\\Polyfill\\Ctype\\": ""
                 },
                 "files": [
                     "bootstrap.php"
@@ -2458,37 +2772,98 @@
             ],
             "authors": [
                 {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
+                    "name": "Gert de Pagter",
+                    "email": "BackEndTea@gmail.com"
                 },
                 {
                     "name": "Symfony Community",
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony polyfill backporting apcu_* functions to lower PHP versions",
+            "description": "Symfony polyfill for ctype functions",
             "homepage": "https://symfony.com",
             "keywords": [
-                "apcu",
                 "compatibility",
+                "ctype",
+                "polyfill",
+                "portable"
+            ],
+            "time": "2020-02-27T09:26:54+00:00"
+        },
+        {
+            "name": "symfony/polyfill-intl-idn",
+            "version": "v1.15.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-idn.git",
+                "reference": "47bd6aa45beb1cd7c6a16b7d1810133b728bdfcf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/47bd6aa45beb1cd7c6a16b7d1810133b728bdfcf",
+                "reference": "47bd6aa45beb1cd7c6a16b7d1810133b728bdfcf",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.3",
+                "symfony/polyfill-mbstring": "^1.3",
+                "symfony/polyfill-php72": "^1.10"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.15-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Idn\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Laurent Bassin",
+                    "email": "laurent@bassin.info"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "idn",
+                "intl",
                 "polyfill",
                 "portable",
                 "shim"
             ],
-            "time": "2017-10-11T12:05:26+00:00"
+            "time": "2020-03-09T19:04:49+00:00"
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.6.0",
+            "version": "v1.15.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "2ec8b39c38cb16674bbf3fea2b6ce5bf117e1296"
+                "reference": "81ffd3a9c6d707be22e3012b827de1c9775fc5ac"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/2ec8b39c38cb16674bbf3fea2b6ce5bf117e1296",
-                "reference": "2ec8b39c38cb16674bbf3fea2b6ce5bf117e1296",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/81ffd3a9c6d707be22e3012b827de1c9775fc5ac",
+                "reference": "81ffd3a9c6d707be22e3012b827de1c9775fc5ac",
                 "shasum": ""
             },
             "require": {
@@ -2500,7 +2875,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.6-dev"
+                    "dev-master": "1.15-dev"
                 }
             },
             "autoload": {
@@ -2534,20 +2909,75 @@
                 "portable",
                 "shim"
             ],
-            "time": "2017-10-11T12:05:26+00:00"
+            "time": "2020-03-09T19:04:49+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php72",
+            "version": "v1.15.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php72.git",
+                "reference": "37b0976c78b94856543260ce09b460a7bc852747"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/37b0976c78b94856543260ce09b460a7bc852747",
+                "reference": "37b0976c78b94856543260ce09b460a7bc852747",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.15-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php72\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "time": "2020-02-27T09:26:54+00:00"
         },
         {
             "name": "symfony/process",
-            "version": "v2.8.33",
+            "version": "v2.8.52",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "ea3226daa3c6789efa39570bfc6e5d55f7561a0a"
+                "reference": "c3591a09c78639822b0b290d44edb69bf9f05dc8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/ea3226daa3c6789efa39570bfc6e5d55f7561a0a",
-                "reference": "ea3226daa3c6789efa39570bfc6e5d55f7561a0a",
+                "url": "https://api.github.com/repos/symfony/process/zipball/c3591a09c78639822b0b290d44edb69bf9f05dc8",
+                "reference": "c3591a09c78639822b0b290d44edb69bf9f05dc8",
                 "shasum": ""
             },
             "require": {
@@ -2583,34 +3013,35 @@
             ],
             "description": "Symfony Process Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2018-11-11T11:18:13+00:00"
         },
         {
             "name": "symfony/translation",
-            "version": "v2.8.33",
+            "version": "v3.3.18",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "a1623f94ec61090fded29ade24e14ce4f4e5878b"
+                "reference": "90cb5ca3eb84b3053fef876e11e405fd819487fc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/a1623f94ec61090fded29ade24e14ce4f4e5878b",
-                "reference": "a1623f94ec61090fded29ade24e14ce4f4e5878b",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/90cb5ca3eb84b3053fef876e11e405fd819487fc",
+                "reference": "90cb5ca3eb84b3053fef876e11e405fd819487fc",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9",
+                "php": "^5.5.9|>=7.0.8",
                 "symfony/polyfill-mbstring": "~1.0"
             },
             "conflict": {
-                "symfony/config": "<2.7"
+                "symfony/config": "<2.8",
+                "symfony/yaml": "<3.3"
             },
             "require-dev": {
                 "psr/log": "~1.0",
-                "symfony/config": "~2.8",
-                "symfony/intl": "~2.7.25|^2.8.18|~3.2.5",
-                "symfony/yaml": "~2.2|~3.0.0"
+                "symfony/config": "~2.8|~3.0",
+                "symfony/intl": "^2.8.18|^3.2.5",
+                "symfony/yaml": "~3.3"
             },
             "suggest": {
                 "psr/log": "To use logging capability in translator",
@@ -2620,7 +3051,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.3-dev"
                 }
             },
             "autoload": {
@@ -2647,29 +3078,35 @@
             ],
             "description": "Symfony Translation Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2018-01-18T14:19:00+00:00"
         },
         {
             "name": "symfony/yaml",
-            "version": "v2.8.33",
+            "version": "v3.3.18",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/yaml.git",
-                "reference": "be720fcfae4614df204190d57795351059946a77"
+                "reference": "af615970e265543a26ee712c958404eb9b7ac93d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/be720fcfae4614df204190d57795351059946a77",
-                "reference": "be720fcfae4614df204190d57795351059946a77",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/af615970e265543a26ee712c958404eb9b7ac93d",
+                "reference": "af615970e265543a26ee712c958404eb9b7ac93d",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9"
+                "php": "^5.5.9|>=7.0.8"
+            },
+            "require-dev": {
+                "symfony/console": "~2.8|~3.0"
+            },
+            "suggest": {
+                "symfony/console": "For validating YAML files using the lint command"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "3.3-dev"
                 }
             },
             "autoload": {
@@ -2696,7 +3133,55 @@
             ],
             "description": "Symfony Yaml Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-03T07:36:31+00:00"
+            "time": "2018-01-20T15:04:53+00:00"
+        },
+        {
+            "name": "webmozart/assert",
+            "version": "1.8.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/webmozart/assert.git",
+                "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/webmozart/assert/zipball/ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
+                "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.3.3 || ^7.0",
+                "symfony/polyfill-ctype": "^1.8"
+            },
+            "conflict": {
+                "vimeo/psalm": "<3.9.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.36 || ^7.5.13"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Webmozart\\Assert\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
+                }
+            ],
+            "description": "Assertions to validate method input/output with nice error messages.",
+            "keywords": [
+                "assert",
+                "check",
+                "validate"
+            ],
+            "time": "2020-04-18T12:12:48+00:00"
         }
     ],
     "aliases": [],
diff --git a/lib/filestorage/file_storage.php b/lib/filestorage/file_storage.php
index c8020e9411b..482d90fe229 100644
--- a/lib/filestorage/file_storage.php
+++ b/lib/filestorage/file_storage.php
@@ -43,16 +43,13 @@ require_once("$CFG->libdir/filestorage/stored_file.php");
  * @since     Moodle 2.0
  */
 class file_storage {
-    /** @var string Directory with file contents */
-    private $filedir;
-    /** @var string Contents of deleted files not needed any more */
-    private $trashdir;
+
     /** @var string tempdir */
     private $tempdir;
-    /** @var int Permissions for new directories */
-    private $dirpermissions;
-    /** @var int Permissions for new files */
-    private $filepermissions;
+
+    /** @var file_system filesystem */
+    private $filesystem;
+
     /** @var array List of formats supported by unoconv */
     private $unoconvformats;
 
@@ -74,43 +71,46 @@ class file_storage {
     /** Any other error */
     const UNOCONVPATH_ERROR = 'error';
 
-
     /**
      * Constructor - do not use directly use {@link get_file_storage()} call instead.
-     *
-     * @param string $filedir full path to pool directory
-     * @param string $trashdir temporary storage of deleted area
-     * @param string $tempdir temporary storage of various files
-     * @param int $dirpermissions new directory permissions
-     * @param int $filepermissions new file permissions
      */
-    public function __construct($filedir, $trashdir, $tempdir, $dirpermissions, $filepermissions) {
-        global $CFG;
+    public function __construct() {
+        // The tempdir must always remain on disk, but shared between all ndoes in a cluster. Its content is not subject
+        // to the file_system abstraction.
+        $this->tempdir = make_temp_directory('filestorage');
 
-        $this->filedir         = $filedir;
-        $this->trashdir        = $trashdir;
-        $this->tempdir         = $tempdir;
-        $this->dirpermissions  = $dirpermissions;
-        $this->filepermissions = $filepermissions;
+        $this->setup_file_system();
+    }
 
-        // make sure the file pool directory exists
-        if (!is_dir($this->filedir)) {
-            if (!mkdir($this->filedir, $this->dirpermissions, true)) {
-                throw new file_exception('storedfilecannotcreatefiledirs'); // permission trouble
-            }
-            // place warning file in file pool root
-            if (!file_exists($this->filedir.'/warning.txt')) {
-                file_put_contents($this->filedir.'/warning.txt',
-                                  'This directory contains the content of uploaded files and is controlled by Moodle code. Do not manually move, change or rename any of the files and subdirectories here.');
-                chmod($this->filedir.'/warning.txt', $CFG->filepermissions);
-            }
-        }
-        // make sure the file pool directory exists
-        if (!is_dir($this->trashdir)) {
-            if (!mkdir($this->trashdir, $this->dirpermissions, true)) {
-                throw new file_exception('storedfilecannotcreatefiledirs'); // permission trouble
+    /**
+     * Complete setup procedure for the file_system component.
+     *
+     * @return file_system
+     */
+    public function setup_file_system() {
+        global $CFG;
+        if ($this->filesystem === null) {
+            require_once($CFG->libdir . '/filestorage/file_system.php');
+            if (!empty($CFG->alternative_file_system_class)) {
+                $class = $CFG->alternative_file_system_class;
+            } else {
+                // The default file_system is the filedir.
+                require_once($CFG->libdir . '/filestorage/file_system_filedir.php');
+                $class = file_system_filedir::class;
             }
+            $this->filesystem = new $class();
         }
+
+        return $this->filesystem;
+    }
+
+    /**
+     * Return the file system instance.
+     *
+     * @return file_system
+     */
+    public function get_file_system() {
+        return $this->filesystem;
     }
 
     /**
@@ -173,7 +173,7 @@ class file_storage {
      * @return stored_file instance of file abstraction class
      */
     public function get_file_instance(stdClass $filerecord) {
-        $storedfile = new stored_file($this, $filerecord, $this->filedir);
+        $storedfile = new stored_file($this, $filerecord);
         return $storedfile;
     }
 
@@ -1478,7 +1478,7 @@ class file_storage {
             $newrecord->id = $DB->insert_record('files', $newrecord);
         } catch (dml_exception $e) {
             if ($newfile) {
-                $this->deleted_file_cleanup($newrecord->contenthash);
+                $this->move_to_trash($newrecord->contenthash);
             }
             throw new stored_file_creation_exception($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid,
                                                     $newrecord->filepath, $newrecord->filename, $e->debuginfo);
@@ -1585,9 +1585,11 @@ class file_storage {
         $newrecord->sortorder    = $filerecord->sortorder;
 
         list($newrecord->contenthash, $newrecord->filesize, $newfile) = $this->add_string_to_pool($content);
-        $filepathname = $this->path_from_hash($newrecord->contenthash) . '/' . $newrecord->contenthash;
-        // get mimetype by magic bytes
-        $newrecord->mimetype = empty($filerecord->mimetype) ? $this->mimetype($filepathname, $filerecord->filename) : $filerecord->mimetype;
+        if (empty($filerecord->mimetype)) {
+            $newrecord->mimetype = $this->filesystem->mimetype_from_hash($newrecord->contenthash, $newrecord->filename);
+        } else {
+            $newrecord->mimetype = $filerecord->mimetype;
+        }
 
         $newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename);
 
@@ -1595,7 +1597,7 @@ class file_storage {
             $newrecord->id = $DB->insert_record('files', $newrecord);
         } catch (dml_exception $e) {
             if ($newfile) {
-                $this->deleted_file_cleanup($newrecord->contenthash);
+                $this->move_to_trash($newrecord->contenthash);
             }
             throw new stored_file_creation_exception($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid,
                                                     $newrecord->filepath, $newrecord->filename, $e->debuginfo);
@@ -1699,16 +1701,19 @@ class file_storage {
             throw new file_reference_exception($repositoryid, $reference, null, null, $e->getMessage());
         }
 
-        if (isset($filerecord->contenthash) && $this->content_exists($filerecord->contenthash)) {
-            // there was specified the contenthash for a file already stored in moodle filepool
+        $existingfile = null;
+        if (isset($filerecord->contenthash)) {
+            $existingfile = $DB->get_record('files', array('contenthash' => $filerecord->contenthash));
+        }
+        if (!empty($existingfile)) {
+            // There is an existing file already available.
             if (empty($filerecord->filesize)) {
-                $filepathname = $this->path_from_hash($filerecord->contenthash) . '/' . $filerecord->contenthash;
-                $filerecord->filesize = filesize($filepathname);
+                $filerecord->filesize = $existingfile->filesize;
             } else {
                 $filerecord->filesize = clean_param($filerecord->filesize, PARAM_INT);
             }
         } else {
-            // atempt to get the result of last synchronisation for this reference
+            // Attempt to get the result of last synchronisation for this reference.
             $lastcontent = $DB->get_record('files', array('referencefileid' => $filerecord->referencefileid),
                     'id, contenthash, filesize', IGNORE_MULTIPLE);
             if ($lastcontent) {
@@ -1727,7 +1732,7 @@ class file_storage {
             $filerecord->id = $DB->insert_record('files', $filerecord);
         } catch (dml_exception $e) {
             if (!empty($newfile)) {
-                $this->deleted_file_cleanup($filerecord->contenthash);
+                $this->move_to_trash($filerecord->contenthash);
             }
             throw new stored_file_creation_exception($filerecord->contextid, $filerecord->component, $filerecord->filearea, $filerecord->itemid,
                                                     $filerecord->filepath, $filerecord->filename, $e->debuginfo);
@@ -1910,99 +1915,7 @@ class file_storage {
      * @return array (contenthash, filesize, newfile)
      */
     public function add_file_to_pool($pathname, $contenthash = NULL) {
-        global $CFG;
-
-        if (!is_readable($pathname)) {
-            throw new file_exception('storedfilecannotread', '', $pathname);
-        }
-
-        $filesize = filesize($pathname);
-        if ($filesize === false) {
-            throw new file_exception('storedfilecannotread', '', $pathname);
-        }
-
-        if (is_null($contenthash)) {
-            $contenthash = sha1_file($pathname);
-        } else if ($CFG->debugdeveloper) {
-            $filehash = sha1_file($pathname);
-            if ($filehash === false) {
-                throw new file_exception('storedfilecannotread', '', $pathname);
-            }
-            if ($filehash !== $contenthash) {
-                // Hopefully this never happens, if yes we need to fix calling code.
-                debugging("Invalid contenthash submitted for file $pathname", DEBUG_DEVELOPER);
-                $contenthash = $filehash;
-            }
-        }
-        if ($contenthash === false) {
-            throw new file_exception('storedfilecannotread', '', $pathname);
-        }
-
-        if ($filesize > 0 and $contenthash === sha1('')) {
-            // Did the file change or is sha1_file() borked for this file?
-            clearstatcache();
-            $contenthash = sha1_file($pathname);
-            $filesize = filesize($pathname);
-
-            if ($contenthash === false or $filesize === false) {
-                throw new file_exception('storedfilecannotread', '', $pathname);
-            }
-            if ($filesize > 0 and $contenthash === sha1('')) {
-                // This is very weird...
-                throw new file_exception('storedfilecannotread', '', $pathname);
-            }
-        }
-
-        $hashpath = $this->path_from_hash($contenthash);
-        $hashfile = "$hashpath/$contenthash";
-
-        $newfile = true;
-
-        if (file_exists($hashfile)) {
-            if (filesize($hashfile) === $filesize) {
-                return array($contenthash, $filesize, false);
-            }
-            if (sha1_file($hashfile) === $contenthash) {
-                // Jackpot! We have a sha1 collision.
-                mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
-                copy($pathname, "$this->filedir/jackpot/{$contenthash}_1");
-                copy($hashfile, "$this->filedir/jackpot/{$contenthash}_2");
-                throw new file_pool_content_exception($contenthash);
-            }
-            debugging("Replacing invalid content file $contenthash");
-            unlink($hashfile);
-            $newfile = false;
-        }
-
-        if (!is_dir($hashpath)) {
-            if (!mkdir($hashpath, $this->dirpermissions, true)) {
-                // Permission trouble.
-                throw new file_exception('storedfilecannotcreatefiledirs');
-            }
-        }
-
-        // Let's try to prevent some race conditions.
-
-        $prev = ignore_user_abort(true);
-        @unlink($hashfile.'.tmp');
-        if (!copy($pathname, $hashfile.'.tmp')) {
-            // Borked permissions or out of disk space.
-            @unlink($hashfile.'.tmp');
-            ignore_user_abort($prev);
-            throw new file_exception('storedfilecannotcreatefile');
-        }
-        if (sha1_file($hashfile.'.tmp') !== $contenthash) {
-            // Highly unlikely edge case, but this can happen on an NFS volume with no space remaining.
-            @unlink($hashfile.'.tmp');
-            ignore_user_abort($prev);
-            throw new file_exception('storedfilecannotcreatefile');
-        }
-        rename($hashfile.'.tmp', $hashfile);
-        chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
-        @unlink($hashfile.'.tmp'); // Just in case anything fails in a weird way.
-        ignore_user_abort($prev);
-
-        return array($contenthash, $filesize, $newfile);
+        return $this->filesystem->add_file_from_path($pathname, $contenthash);
     }
 
     /**
@@ -2012,66 +1925,7 @@ class file_storage {
      * @return array (contenthash, filesize, newfile)
      */
     public function add_string_to_pool($content) {
-        global $CFG;
-
-        $contenthash = sha1($content);
-        $filesize = strlen($content); // binary length
-
-        $hashpath = $this->path_from_hash($contenthash);
-        $hashfile = "$hashpath/$contenthash";
-
-        $newfile = true;
-
-        if (file_exists($hashfile)) {
-            if (filesize($hashfile) === $filesize) {
-                return array($contenthash, $filesize, false);
-            }
-            if (sha1_file($hashfile) === $contenthash) {
-                // Jackpot! We have a sha1 collision.
-                mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
-                copy($hashfile, "$this->filedir/jackpot/{$contenthash}_1");
-                file_put_contents("$this->filedir/jackpot/{$contenthash}_2", $content);
-                throw new file_pool_content_exception($contenthash);
-            }
-            debugging("Replacing invalid content file $contenthash");
-            unlink($hashfile);
-            $newfile = false;
-        }
-
-        if (!is_dir($hashpath)) {
-            if (!mkdir($hashpath, $this->dirpermissions, true)) {
-                // Permission trouble.
-                throw new file_exception('storedfilecannotcreatefiledirs');
-            }
-        }
-
-        // Hopefully this works around most potential race conditions.
-
-        $prev = ignore_user_abort(true);
-
-        if (!empty($CFG->preventfilelocking)) {
-            $newsize = file_put_contents($hashfile.'.tmp', $content);
-        } else {
-            $newsize = file_put_contents($hashfile.'.tmp', $content, LOCK_EX);
-        }
-
-        if ($newsize === false) {
-            // Borked permissions most likely.
-            ignore_user_abort($prev);
-            throw new file_exception('storedfilecannotcreatefile');
-        }
-        if (filesize($hashfile.'.tmp') !== $filesize) {
-            // Out of disk space?
-            unlink($hashfile.'.tmp');
-            ignore_user_abort($prev);
-            throw new file_exception('storedfilecannotcreatefile');
-        }
-        rename($hashfile.'.tmp', $hashfile);
-        chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
-        @unlink($hashfile.'.tmp'); // Just in case anything fails in a weird way.
-        ignore_user_abort($prev);
-
-        return array($contenthash, $filesize, $newfile);
+        return $this->filesystem->add_file_from_string($content);
     }
 
     /**
@@ -2083,11 +1937,7 @@ class file_storage {
      * @return bool success
      */
     public function xsendfile($contenthash) {
-        global $CFG;
-        require_once("$CFG->libdir/xsendfilelib.php");
-
-        $hashpath = $this->path_from_hash($contenthash);
-        return xsendfile("$hashpath/$contenthash");
+        return $this->filesystem->xsendfile($contenthash);
     }
 
     /**
@@ -2095,39 +1945,12 @@ class file_storage {
      *
      * @param string $contenthash
      * @return bool
+     * @deprecated since 3.3
      */
     public function content_exists($contenthash) {
-        $dir = $this->path_from_hash($contenthash);
-        $filepath = $dir . '/' . $contenthash;
-        return file_exists($filepath);
-    }
+        debugging('The content_exists function has been deprecated and should no longer be used.', DEBUG_DEVELOPER);
 
-    /**
-     * Return path to file with given hash.
-     *
-     * NOTE: must not be public, files in pool must not be modified
-     *
-     * @param string $contenthash content hash
-     * @return string expected file location
-     */
-    protected function path_from_hash($contenthash) {
-        $l1 = $contenthash[0].$contenthash[1];
-        $l2 = $contenthash[2].$contenthash[3];
-        return "$this->filedir/$l1/$l2";
-    }
-
-    /**
-     * Return path to file with given hash.
-     *
-     * NOTE: must not be public, files in pool must not be modified
-     *
-     * @param string $contenthash content hash
-     * @return string expected file location
-     */
-    protected function trash_path_from_hash($contenthash) {
-        $l1 = $contenthash[0].$contenthash[1];
-        $l2 = $contenthash[2].$contenthash[3];
-        return "$this->trashdir/$l1/$l2";
+        return false;
     }
 
     /**
@@ -2135,74 +1958,12 @@ class file_storage {
      *
      * @param stored_file $file stored_file instance
      * @return bool success
+     * @deprecated since 3.3
      */
     public function try_content_recovery($file) {
-        $contenthash = $file->get_contenthash();
-        $trashfile = $this->trash_path_from_hash($contenthash).'/'.$contenthash;
-        if (!is_readable($trashfile)) {
-            if (!is_readable($this->trashdir.'/'.$contenthash)) {
-                return false;
-            }
-            // nice, at least alternative trash file in trash root exists
-            $trashfile = $this->trashdir.'/'.$contenthash;
-        }
-        if (filesize($trashfile) != $file->get_filesize() or sha1_file($trashfile) != $contenthash) {
-            //weird, better fail early
-            return false;
-        }
-        $contentdir  = $this->path_from_hash($contenthash);
-        $contentfile = $contentdir.'/'.$contenthash;
-        if (file_exists($contentfile)) {
-            //strange, no need to recover anything
-            return true;
-        }
-        if (!is_dir($contentdir)) {
-            if (!mkdir($contentdir, $this->dirpermissions, true)) {
-                return false;
-            }
-        }
-        return rename($trashfile, $contentfile);
-    }
+        debugging('The try_content_recovery function has been deprecated and should no longer be used.', DEBUG_DEVELOPER);
 
-    /**
-     * Marks pool file as candidate for deleting.
-     *
-     * DO NOT call directly - reserved for core!!
-     *
-     * @param string $contenthash
-     */
-    public function deleted_file_cleanup($contenthash) {
-        global $DB;
-
-        if ($contenthash === sha1('')) {
-            // No need to delete empty content file with sha1('') content hash.
-            return;
-        }
-
-        //Note: this section is critical - in theory file could be reused at the same
-        //      time, if this happens we can still recover the file from trash
-        if ($DB->record_exists('files', array('contenthash'=>$contenthash))) {
-            // file content is still used
-            return;
-        }
-        //move content file to trash
-        $contentfile = $this->path_from_hash($contenthash).'/'.$contenthash;
-        if (!file_exists($contentfile)) {
-            //weird, but no problem
-            return;
-        }
-        $trashpath = $this->trash_path_from_hash($contenthash);
-        $trashfile = $trashpath.'/'.$contenthash;
-        if (file_exists($trashfile)) {
-            // we already have this content in trash, no need to move it there
-            unlink($contentfile);
-            return;
-        }
-        if (!is_dir($trashpath)) {
-            mkdir($trashpath, $this->dirpermissions, true);
-        }
-        rename($contentfile, $trashfile);
-        chmod($trashfile, $this->filepermissions); // fix permissions if needed
+        return false;
     }
 
     /**
@@ -2481,27 +2242,46 @@ class file_storage {
     }
 
     /**
-     * Return mimetype by given file pathname
+     * Return mimetype by given file pathname.
      *
      * If file has a known extension, we return the mimetype based on extension.
      * Otherwise (when possible) we try to get the mimetype from file contents.
      *
-     * @param string $pathname full path to the file
-     * @param string $filename correct file name with extension, if omitted will be taken from $path
+     * @param string $fullpath Full path to the file on disk
+     * @param string $filename Correct file name with extension, if omitted will be taken from $path
      * @return string
      */
-    public static function mimetype($pathname, $filename = null) {
+    public static function mimetype($fullpath, $filename = null) {
         if (empty($filename)) {
-            $filename = $pathname;
+            $filename = $fullpath;
         }
+
+        // The mimeinfo function determines the mimetype purely based on the file extension.
         $type = mimeinfo('type', $filename);
-        if ($type === 'document/unknown' && class_exists('finfo') && file_exists($pathname)) {
-            $finfo = new finfo(FILEINFO_MIME_TYPE);
-            $type = mimeinfo_from_type('type', $finfo->file($pathname));
+
+        if ($type === 'document/unknown') {
+            // The type is unknown. Inspect the file now.
+            $type = self::mimetype_from_file($fullpath);
         }
         return $type;
     }
 
+    /**
+     * Inspect a file on disk for it's mimetype.
+     *
+     * @param string $fullpath Path to file on disk
+     * @return string The mimetype
+     */
+    public static function mimetype_from_file($fullpath) {
+        if (file_exists($fullpath)) {
+            // The type is unknown. Attempt to look up the file type now.
+            $finfo = new finfo(FILEINFO_MIME_TYPE);
+            return mimeinfo_from_type('type', $finfo->file($fullpath));
+        }
+
+        return 'document/unknown';
+    }
+
     /**
      * Cron cleanup job.
      */
@@ -2586,10 +2366,9 @@ class file_storage {
             $rs->close();
             mtrace('done.');
 
-            mtrace('Deleting trash files... ', '');
+            mtrace('Call filesystem cron tasks.', '');
             cron_trace_time_and_memory();
-            fulldelete($this->trashdir);
-            set_config('fileslastcleanup', time());
+            $this->filesystem->cron();
             mtrace('done.');
         }
     }
diff --git a/lib/filestorage/file_system.php b/lib/filestorage/file_system.php
new file mode 100644
index 00000000000..bfee09ca226
--- /dev/null
+++ b/lib/filestorage/file_system.php
@@ -0,0 +1,561 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Core file system class definition.
+ *
+ * @package   core_files
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * File system class used for low level access to real files in filedir.
+ *
+ * @package   core_files
+ * @category  files
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class file_system {
+
+    /**
+     * Private clone method to prevent cloning of the instance.
+     */
+    final protected function __clone() {
+        return;
+    }
+
+    /**
+     * Private wakeup method to prevent unserialising of the instance.
+     */
+    final protected function __wakeup() {
+        return;
+    }
+
+    /**
+     * Output the content of the specified stored file.
+     *
+     * Note, this is different to get_content() as it uses the built-in php
+     * readfile function which is more efficient.
+     *
+     * @param stored_file $file The file to serve.
+     * @return void
+     */
+    public function readfile(stored_file $file) {
+        if ($this->is_file_readable_locally_by_storedfile($file, false)) {
+            $path = $this->get_local_path_from_storedfile($file, false);
+        } else {
+            $path = $this->get_remote_path_from_storedfile($file);
+        }
+        readfile_allow_large($path, $file->get_filesize());
+    }
+
+    /**
+     * Get the full path on disk for the specified stored file.
+     *
+     * Note: This must return a consistent path for the file's contenthash
+     * and the path _will_ be in a standard local format.
+     * Streamable paths will not work.
+     * A local copy of the file _will_ be fetched if $fetchifnotfound is tree.
+     *
+     * The $fetchifnotfound allows you to determine the expected path of the file.
+     *
+     * @param stored_file $file The file to serve.
+     * @param bool $fetchifnotfound Whether to attempt to fetch from the remote path if not found.
+     * @return string full path to pool file with file content
+     */
+    protected function get_local_path_from_storedfile(stored_file $file, $fetchifnotfound = false) {
+        return $this->get_local_path_from_hash($file->get_contenthash(), $fetchifnotfound);
+    }
+
+    /**
+     * Get a remote filepath for the specified stored file.
+     *
+     * This is typically either the same as the local filepath, or it is a streamable resource.
+     *
+     * See https://secure.php.net/manual/en/wrappers.php for further information on valid wrappers.
+     *
+     * @param stored_file $file The file to serve.
+     * @return string full path to pool file with file content
+     */
+    protected function get_remote_path_from_storedfile(stored_file $file) {
+        return $this->get_remote_path_from_hash($file->get_contenthash(), false);
+    }
+
+    /**
+     * Get the full path for the specified hash, including the path to the filedir.
+     *
+     * Note: This must return a consistent path for the file's contenthash
+     * and the path _will_ be in a standard local format.
+     * Streamable paths will not work.
+     * A local copy of the file _will_ be fetched if $fetchifnotfound is tree.
+     *
+     * The $fetchifnotfound allows you to determine the expected path of the file.
+     *
+     * @param string $contenthash The content hash
+     * @param bool $fetchifnotfound Whether to attempt to fetch from the remote path if not found.
+     * @return string The full path to the content file
+     */
+    abstract protected function get_local_path_from_hash($contenthash, $fetchifnotfound = false);
+
+    /**
+     * Get the full path for the specified hash, including the path to the filedir.
+     *
+     * This is typically either the same as the local filepath, or it is a streamable resource.
+     *
+     * See https://secure.php.net/manual/en/wrappers.php for further information on valid wrappers.
+     *
+     * @param string $contenthash The content hash
+     * @return string The full path to the content file
+     */
+    abstract protected function get_remote_path_from_hash($contenthash);
+
+    /**
+     * Determine whether the file is present on the file system somewhere.
+     * A local copy of the file _will_ be fetched if $fetchifnotfound is tree.
+     *
+     * The $fetchifnotfound allows you to determine the expected path of the file.
+     *
+     * @param stored_file $file The file to ensure is available.
+     * @param bool $fetchifnotfound Whether to attempt to fetch from the remote path if not found.
+     * @return bool
+     */
+    public function is_file_readable_locally_by_storedfile(stored_file $file, $fetchifnotfound = false) {
+        if (!$file->get_filesize()) {
+            // Files with empty size are either directories or empty.
+            // We handle these virtually.
+            return true;
+        }
+
+        // Check to see if the file is currently readable.
+        $path = $this->get_local_path_from_storedfile($file, $fetchifnotfound);
+        if (is_readable($path)) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Determine whether the file is present on the local file system somewhere.
+     *
+     * @param stored_file $file The file to ensure is available.
+     * @return bool
+     */
+    public function is_file_readable_remotely_by_storedfile(stored_file $file) {
+        if (!$file->get_filesize()) {
+            // Files with empty size are either directories or empty.
+            // We handle these virtually.
+            return true;
+        }
+
+        $path = $this->get_remote_path_from_storedfile($file, false);
+        if (is_readable($path)) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Determine whether the file is present on the file system somewhere given
+     * the contenthash.
+     *
+     * @param string $contenthash The contenthash of the file to check.
+     * @param bool $fetchifnotfound Whether to attempt to fetch from the remote path if not found.
+     * @return bool
+     */
+    public function is_file_readable_locally_by_hash($contenthash, $fetchifnotfound = false) {
+        if ($contenthash === sha1('')) {
+            // Files with empty size are either directories or empty.
+            // We handle these virtually.
+            return true;
+        }
+
+        // This is called by file_storage::content_exists(), and in turn by the repository system.
+        $path = $this->get_local_path_from_hash($contenthash, $fetchifnotfound);
+
+        // Note - it is not possible to perform a content recovery safely from a hash alone.
+        return is_readable($path);
+    }
+
+    /**
+     * Determine whether the file is present locally on the file system somewhere given
+     * the contenthash.
+     *
+     * @param string $contenthash The contenthash of the file to check.
+     * @return bool
+     */
+    public function is_file_readable_remotely_by_hash($contenthash) {
+        if ($contenthash === sha1('')) {
+            // Files with empty size are either directories or empty.
+            // We handle these virtually.
+            return true;
+        }
+
+        $path = $this->get_remote_path_from_hash($contenthash, false);
+
+        // Note - it is not possible to perform a content recovery safely from a hash alone.
+        return is_readable($path);
+    }
+
+    /**
+     * Copy content of file to given pathname.
+     *
+     * @param stored_file $file The file to be copied
+     * @param string $target real path to the new file
+     * @return bool success
+     */
+    abstract public function copy_content_from_storedfile(stored_file $file, $target);
+
+    /**
+     * Remove the file with the specified contenthash.
+     *
+     * Note, if overriding this function, you _must_ check that the file is
+     * no longer in use - see {check_file_usage}.
+     *
+     * DO NOT call directly - reserved for core!!
+     *
+     * @param string $contenthash
+     */
+    abstract public function remove_file($contenthash);
+
+    /**
+     * Check whether a file is removable.
+     *
+     * This must be called prior to file removal.
+     *
+     * @param string $contenthash
+     * @return bool
+     */
+    protected static function is_file_removable($contenthash) {
+        global $DB;
+
+        if ($contenthash === sha1('')) {
+            // No need to delete empty content file with sha1('') content hash.
+            return false;
+        }
+
+        // Note: This section is critical - in theory file could be reused at the same time, if this
+        // happens we can still recover the file from trash.
+        // Technically this is the responsibility of the file_storage API, but as this method is public, we go belt-and-braces.
+        if ($DB->record_exists('files', array('contenthash' => $contenthash))) {
+            // File content is still used.
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Get the content of the specified stored file.
+     *
+     * Generally you will probably want to use readfile() to serve content,
+     * and where possible you should see if you can use
+     * get_content_file_handle and work with the file stream instead.
+     *
+     * @param stored_file $file The file to retrieve
+     * @return string The full file content
+     */
+    public function get_content(stored_file $file) {
+        if (!$file->get_filesize()) {
+            // Directories are empty. Empty files are not worth fetching.
+            return '';
+        }
+
+        $source = $this->get_remote_path_from_storedfile($file);
+        return file_get_contents($source);
+    }
+
+    /**
+     * List contents of archive.
+     *
+     * @param stored_file $file The archive to inspect
+     * @param file_packer $packer file packer instance
+     * @return array of file infos
+     */
+    public function list_files($file, file_packer $packer) {
+        $archivefile = $this->get_local_path_from_storedfile($file, true);
+        return $packer->list_files($archivefile);
+    }
+
+    /**
+     * Extract file to given file path (real OS filesystem), existing files are overwritten.
+     *
+     * @param stored_file $file The archive to inspect
+     * @param file_packer $packer File packer instance
+     * @param string $pathname Target directory
+     * @param file_progress $progress progress indicator callback or null if not required
+     * @return array|bool List of processed files; false if error
+     */
+    public function extract_to_pathname(stored_file $file, file_packer $packer, $pathname, file_progress $progress = null) {
+        $archivefile = $this->get_local_path_from_storedfile($file, true);
+        return $packer->extract_to_pathname($archivefile, $pathname, null, $progress);
+    }
+
+    /**
+     * Extract file to given file path (real OS filesystem), existing files are overwritten.
+     *
+     * @param stored_file $file The archive to inspect
+     * @param file_packer $packer file packer instance
+     * @param int $contextid context ID
+     * @param string $component component
+     * @param string $filearea file area
+     * @param int $itemid item ID
+     * @param string $pathbase path base
+     * @param int $userid user ID
+     * @param file_progress $progress Progress indicator callback or null if not required
+     * @return array|bool list of processed files; false if error
+     */
+    public function extract_to_storage(stored_file $file, file_packer $packer, $contextid,
+            $component, $filearea, $itemid, $pathbase, $userid = null, file_progress $progress = null) {
+
+        // Since we do not know which extractor we have, and whether it supports remote paths, use a local path here.
+        $archivefile = $this->get_local_path_from_storedfile($file, true);
+        return $packer->extract_to_storage($archivefile, $contextid,
+                $component, $filearea, $itemid, $pathbase, $userid, $progress);
+    }
+
+    /**
+     * Add file/directory into archive.
+     *
+     * @param stored_file $file The file to archive
+     * @param file_archive $filearch file archive instance
+     * @param string $archivepath pathname in archive
+     * @return bool success
+     */
+    public function add_storedfile_to_archive(stored_file $file, file_archive $filearch, $archivepath) {
+        if ($file->is_directory()) {
+            return $filearch->add_directory($archivepath);
+        } else {
+            // Since we do not know which extractor we have, and whether it supports remote paths, use a local path here.
+            return $filearch->add_file_from_pathname($archivepath, $this->get_local_path_from_storedfile($file, true));
+        }
+    }
+
+    /**
+     * Adds this file path to a curl request (POST only).
+     *
+     * @param stored_file $file The file to add to the curl request
+     * @param curl $curlrequest The curl request object
+     * @param string $key What key to use in the POST request
+     * @return void
+     * This needs the fullpath for the storedfile :/
+     * Can this be achieved in some other fashion?
+     */
+    public function add_to_curl_request(stored_file $file, &$curlrequest, $key) {
+        // Note: curl_file_create does not work with remote paths.
+        $path = $this->get_local_path_from_storedfile($file, true);
+        $curlrequest->_tmp_file_post_params[$key] = curl_file_create($path);
+    }
+
+    /**
+     * Returns information about image.
+     * Information is determined from the file content
+     *
+     * @param stored_file $file The file to inspect
+     * @return mixed array with width, height and mimetype; false if not an image
+     */
+    public function get_imageinfo(stored_file $file) {
+        if (!$this->is_image_from_storedfile($file)) {
+            return false;
+        }
+
+        // Whilst get_imageinfo_from_path can use remote paths, it must download the entire file first.
+        // It is more efficient to use a local file when possible.
+        return $this->get_imageinfo_from_path($this->get_local_path_from_storedfile($file, true));
+    }
+
+    /**
+     * Attempt to determine whether the specified file is likely to be an
+     * image.
+     * Since this relies upon the mimetype stored in the files table, there
+     * may be times when this information is not 100% accurate.
+     *
+     * @param stored_file $file The file to check
+     * @return bool
+     */
+    public function is_image_from_storedfile(stored_file $file) {
+        if (!$file->get_filesize()) {
+            // An empty file cannot be an image.
+            return false;
+        }
+
+        $mimetype = $file->get_mimetype();
+        if (!preg_match('|^image/|', $mimetype)) {
+            // The mimetype does not include image.
+            return false;
+        }
+
+        // If it looks like an image, and it smells like an image, perhaps it's an image!
+        return true;
+    }
+
+    /**
+     * Returns image information relating to the specified path or URL.
+     *
+     * @param string $path The path to pass to getimagesize.
+     * @return array Containing width, height, and mimetype.
+     */
+    protected function get_imageinfo_from_path($path) {
+        $imageinfo = getimagesize($path);
+
+        $image = array(
+                'width'     => $imageinfo[0],
+                'height'    => $imageinfo[1],
+                'mimetype'  => image_type_to_mime_type($imageinfo[2]),
+            );
+        if (empty($image['width']) or empty($image['height']) or empty($image['mimetype'])) {
+            // GD can not parse it, sorry.
+            return false;
+        }
+        return $image;
+    }
+
+    /**
+     * Serve file content using X-Sendfile header.
+     * Please make sure that all headers are already sent and the all
+     * access control checks passed.
+     *
+     * @param string $contenthash The content hash of the file to be served
+     * @return bool success
+     */
+    public function xsendfile($contenthash) {
+        global $CFG;
+        require_once($CFG->libdir . "/xsendfilelib.php");
+
+        return xsendfile($this->get_remote_path_from_hash($contenthash));
+    }
+
+    /**
+     * Add the supplied file to the file system.
+     *
+     * Note: If overriding this function, it is advisable to store the file
+     * in the path returned by get_local_path_from_hash as there may be
+     * subsequent uses of the file in the same request.
+     *
+     * @param string $pathname Path to file currently on disk
+     * @param string $contenthash SHA1 hash of content if known (performance only)
+     * @return array (contenthash, filesize, newfile)
+     */
+    abstract public function add_file_from_path($pathname, $contenthash = null);
+
+    /**
+     * Add a file with the supplied content to the file system.
+     *
+     * Note: If overriding this function, it is advisable to store the file
+     * in the path returned by get_local_path_from_hash as there may be
+     * subsequent uses of the file in the same request.
+     *
+     * @param string $content file content - binary string
+     * @return array (contenthash, filesize, newfile)
+     */
+    abstract public function add_file_from_string($content);
+
+    /**
+     * Returns file handle - read only mode, no writing allowed into pool files!
+     *
+     * When you want to modify a file, create a new file and delete the old one.
+     *
+     * @param stored_file $file The file to retrieve a handle for
+     * @param int $type Type of file handle (FILE_HANDLE_xx constant)
+     * @return resource file handle
+     */
+    public function get_content_file_handle(stored_file $file, $type = stored_file::FILE_HANDLE_FOPEN) {
+        $path = $this->get_remote_path_from_storedfile($file);
+
+        return self::get_file_handle_for_path($path, $type);
+    }
+
+    /**
+     * Return a file handle for the specified path.
+     *
+     * This abstraction should be used when overriding get_content_file_handle in a new file system.
+     *
+     * @param string $path The path to the file. This shoudl be any type of path that fopen and gzopen accept.
+     * @param int $type Type of file handle (FILE_HANDLE_xx constant)
+     * @return resource
+     * @throws coding_exception When an unexpected type of file handle is requested
+     */
+    protected static function get_file_handle_for_path($path, $type = stored_file::FILE_HANDLE_FOPEN) {
+        switch ($type) {
+            case stored_file::FILE_HANDLE_FOPEN:
+                // Binary reading.
+                return fopen($path, 'rb');
+            case stored_file::FILE_HANDLE_GZOPEN:
+                // Binary reading of file in gz format.
+                return gzopen($path, 'rb');
+            default:
+                throw new coding_exception('Unexpected file handle type');
+        }
+    }
+
+    /**
+     * Retrieve the mime information for the specified stored file.
+     *
+     * @param string $contenthash
+     * @param string $filename
+     * @return string The MIME type.
+     */
+    public function mimetype_from_hash($contenthash, $filename) {
+        $pathname = $this->get_remote_path_from_hash($contenthash);
+        $mimetype = file_storage::mimetype($pathname, $filename);
+
+        if (!$this->is_file_readable_locally_by_hash($contenthash, false) && $mimetype === 'document/unknown') {
+            // The type is unknown, but the full checks weren't completed because the file isn't locally available.
+            // Ensure we have a local copy and try again.
+            $pathname = $this->get_local_path_from_hash($contenthash, true);
+
+            $mimetype = file_storage::mimetype_from_file($pathname);
+        }
+
+        return $mimetype;
+    }
+
+    /**
+     * Retrieve the mime information for the specified stored file.
+     *
+     * @param stored_file $file The stored file to retrieve mime information for
+     * @return string The MIME type.
+     */
+    public function mimetype_from_storedfile($file) {
+        if (!$file->get_filesize()) {
+            // Files with an empty filesize are treated as directories and have no mimetype.
+            return null;
+        }
+        $pathname = $this->get_remote_path_from_storedfile($file);
+        $mimetype = file_storage::mimetype($pathname, $file->get_filename());
+
+        if (!$this->is_file_readable_locally_by_storedfile($file) && $mimetype === 'document/unknown') {
+            // The type is unknown, but the full checks weren't completed because the file isn't locally available.
+            // Ensure we have a local copy and try again.
+            $pathname = $this->get_local_path_from_storedfile($file, true);
+
+            $mimetype = file_storage::mimetype_from_file($pathname);
+        }
+
+        return $mimetype;
+    }
+
+    /**
+     * Run any periodic tasks which must be performed.
+     */
+    public function cron() {
+    }
+}
diff --git a/lib/filestorage/file_system_filedir.php b/lib/filestorage/file_system_filedir.php
new file mode 100644
index 00000000000..b307a5dc758
--- /dev/null
+++ b/lib/filestorage/file_system_filedir.php
@@ -0,0 +1,515 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Core file system class definition.
+ *
+ * @package   core_files
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * File system class used for low level access to real files in filedir.
+ *
+ * @package   core_files
+ * @category  files
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class file_system_filedir extends file_system {
+
+    /**
+     * @var string The path to the local copy of the filedir.
+     */
+    protected $filedir = null;
+
+    /**
+     * @var string The path to the trashdir.
+     */
+    protected $trashdir = null;
+
+    /**
+     * @var string Default directory permissions for new dirs.
+     */
+    protected $dirpermissions = null;
+
+    /**
+     * @var string Default file permissions for new files.
+     */
+    protected $filepermissions = null;
+
+
+    /**
+     * Perform any custom setup for this type of file_system.
+     */
+    public function __construct() {
+        global $CFG;
+
+        if (isset($CFG->filedir)) {
+            $this->filedir = $CFG->filedir;
+        } else {
+            $this->filedir = $CFG->dataroot.'/filedir';
+        }
+
+        if (isset($CFG->trashdir)) {
+            $this->trashdir = $CFG->trashdir;
+        } else {
+            $this->trashdir = $CFG->dataroot.'/trashdir';
+        }
+
+        $this->dirpermissions = $CFG->directorypermissions;
+        $this->filepermissions = $CFG->filepermissions;
+
+        // Make sure the file pool directory exists.
+        if (!is_dir($this->filedir)) {
+            if (!mkdir($this->filedir, $this->dirpermissions, true)) {
+                // Permission trouble.
+                throw new file_exception('storedfilecannotcreatefiledirs');
+            }
+
+            // Place warning file in file pool root.
+            if (!file_exists($this->filedir.'/warning.txt')) {
+                file_put_contents($this->filedir.'/warning.txt',
+                        'This directory contains the content of uploaded files and is controlled by Moodle code. ' .
+                        'Do not manually move, change or rename any of the files and subdirectories here.');
+                chmod($this->filedir . '/warning.txt', $this->filepermissions);
+            }
+        }
+
+        // Make sure the trashdir directory exists too.
+        if (!is_dir($this->trashdir)) {
+            if (!mkdir($this->trashdir, $this->dirpermissions, true)) {
+                // Permission trouble.
+                throw new file_exception('storedfilecannotcreatefiledirs');
+            }
+        }
+    }
+
+    /**
+     * Get the full path for the specified hash, including the path to the filedir.
+     *
+     * @param string $contenthash The content hash
+     * @param bool $fetchifnotfound Whether to attempt to fetch from the remote path if not found.
+     * @return string The full path to the content file
+     */
+    protected function get_local_path_from_hash($contenthash, $fetchifnotfound = false) {
+        return $this->get_fulldir_from_hash($contenthash) . DIRECTORY_SEPARATOR . $contenthash;
+    }
+
+    /**
+     * Get a remote filepath for the specified stored file.
+     *
+     * @param stored_file $file The file to fetch the path for
+     * @param bool $fetchifnotfound Whether to attempt to fetch from the remote path if not found.
+     * @return string The full path to the content file
+     */
+    protected function get_local_path_from_storedfile(stored_file $file, $fetchifnotfound = false) {
+        $filepath = $this->get_local_path_from_hash($file->get_contenthash(), $fetchifnotfound);
+
+        // Try content recovery.
+        if ($fetchifnotfound && !is_readable($filepath)) {
+            $this->recover_file($file);
+        }
+
+        return $filepath;
+    }
+
+    /**
+     * Get a remote filepath for the specified stored file.
+     *
+     * @param stored_file $file The file to serve.
+     * @return string full path to pool file with file content
+     */
+    protected function get_remote_path_from_storedfile(stored_file $file) {
+        return $this->get_local_path_from_storedfile($file, false);
+    }
+
+    /**
+     * Get the full path for the specified hash, including the path to the filedir.
+     *
+     * @param string $contenthash The content hash
+     * @return string The full path to the content file
+     */
+    protected function get_remote_path_from_hash($contenthash) {
+        return $this->get_local_path_from_hash($contenthash, false);
+    }
+
+    /**
+     * Get the full directory to the stored file, including the path to the
+     * filedir, and the directory which the file is actually in.
+     *
+     * Note: This function does not ensure that the file is present on disk.
+     *
+     * @param stored_file $file The file to fetch details for.
+     * @return string The full path to the content directory
+     */
+    protected function get_fulldir_from_storedfile(stored_file $file) {
+        return $this->get_fulldir_from_hash($file->get_contenthash());
+    }
+
+    /**
+     * Get the full directory to the stored file, including the path to the
+     * filedir, and the directory which the file is actually in.
+     *
+     * @param string $contenthash The content hash
+     * @return string The full path to the content directory
+     */
+    protected function get_fulldir_from_hash($contenthash) {
+        return $this->filedir . DIRECTORY_SEPARATOR . $this->get_contentdir_from_hash($contenthash);
+    }
+
+    /**
+     * Get the content directory for the specified content hash.
+     * This is the directory that the file will be in, but without the
+     * fulldir.
+     *
+     * @param string $contenthash The content hash
+     * @return string The directory within filedir
+     */
+    protected function get_contentdir_from_hash($contenthash) {
+        $l1 = $contenthash[0] . $contenthash[1];
+        $l2 = $contenthash[2] . $contenthash[3];
+        return "$l1/$l2";
+    }
+
+    /**
+     * Get the content path for the specified content hash within filedir.
+     *
+     * This does not include the filedir, and is often used by file systems
+     * as the object key for storage and retrieval.
+     *
+     * @param string $contenthash The content hash
+     * @return string The filepath within filedir
+     */
+    protected function get_contentpath_from_hash($contenthash) {
+        return $this->get_contentdir_from_hash($contenthash) . "/$contenthash";
+    }
+
+    /**
+     * Get the full directory for the specified hash in the trash, including the path to the
+     * trashdir, and the directory which the file is actually in.
+     *
+     * @param string $contenthash The content hash
+     * @return string The full path to the trash directory
+     */
+    protected function get_trash_fulldir_from_hash($contenthash) {
+        return $this->trashdir . DIRECTORY_SEPARATOR . $this->get_contentdir_from_hash($contenthash);
+    }
+
+    /**
+     * Get the full path for the specified hash in the trash, including the path to the trashdir.
+     *
+     * @param string $contenthash The content hash
+     * @return string The full path to the trash file
+     */
+    protected function get_trash_fullpath_from_hash($contenthash) {
+        return $this->trashdir . DIRECTORY_SEPARATOR . $this->get_contentpath_from_hash($contenthash);
+    }
+
+    /**
+     * Copy content of file to given pathname.
+     *
+     * @param stored_file $file The file to be copied
+     * @param string $target real path to the new file
+     * @return bool success
+     */
+    public function copy_content_from_storedfile(stored_file $file, $target) {
+        $source = $this->get_local_path_from_storedfile($file, true);
+        return copy($source, $target);
+    }
+
+    /**
+     * Tries to recover missing content of file from trash.
+     *
+     * @param stored_file $file stored_file instance
+     * @return bool success
+     */
+    protected function recover_file(stored_file $file) {
+        $contentfile = $this->get_local_path_from_storedfile($file, false);
+
+        if (file_exists($contentfile)) {
+            // The file already exists on the file system. No need to recover.
+            return true;
+        }
+
+        $contenthash = $file->get_contenthash();
+        $contentdir = $this->get_fulldir_from_storedfile($file);
+        $trashfile = $this->get_trash_fullpath_from_hash($contenthash);
+        $alttrashfile = $this->trashdir . DIRECTORY_SEPARATOR . $contenthash;
+
+        if (!is_readable($trashfile)) {
+            // The trash file was not found. Check the alternative trash file too just in case.
+            if (!is_readable($alttrashfile)) {
+                return false;
+            }
+            // The alternative trash file in trash root exists.
+            $trashfile = $alttrashfile;
+        }
+
+        if (filesize($trashfile) != $file->get_filesize() or sha1_file($trashfile) != $contenthash) {
+            // The files are different. Leave this one in trash - something seems to be wrong with it.
+            return false;
+        }
+
+        if (!is_dir($contentdir)) {
+            if (!mkdir($contentdir, $this->dirpermissions, true)) {
+                // Unable to create the target directory.
+                return false;
+            }
+        }
+
+        // Perform a rename - these are generally atomic which gives us big
+        // performance wins, especially for large files.
+        return rename($trashfile, $contentfile);
+    }
+
+    /**
+     * Marks pool file as candidate for deleting.
+     *
+     * @param string $contenthash
+     */
+    public function remove_file($contenthash) {
+        if (!self::is_file_removable($contenthash)) {
+            // Don't remove the file - it's still in use.
+            return;
+        }
+
+        if (!$this->is_file_readable_remotely_by_hash($contenthash)) {
+            // The file wasn't found in the first place. Just ignore it.
+            return;
+        }
+
+        $trashpath  = $this->get_trash_fulldir_from_hash($contenthash);
+        $trashfile  = $this->get_trash_fullpath_from_hash($contenthash);
+        $contentfile = $this->get_local_path_from_hash($contenthash, true);
+
+        if (!is_dir($trashpath)) {
+            mkdir($trashpath, $this->dirpermissions, true);
+        }
+
+        if (file_exists($trashfile)) {
+            // A copy of this file is already in the trash.
+            // Remove the old version.
+            unlink($contentfile);
+            return;
+        }
+
+        // Move the contentfile to the trash, and fix permissions as required.
+        rename($contentfile, $trashfile);
+
+        // Fix permissions, only if needed.
+        $currentperms = octdec(substr(decoct(fileperms($trashfile)), -4));
+        if ((int)$this->filepermissions !== $currentperms) {
+            chmod($trashfile, $this->filepermissions);
+        }
+    }
+
+    /**
+     * Cleanup the trash directory.
+     */
+    public function cron() {
+        $this->empty_trash();
+    }
+
+    protected function empty_trash() {
+        fulldelete($this->trashdir);
+        set_config('fileslastcleanup', time());
+    }
+
+    /**
+     * Add the supplied file to the file system.
+     *
+     * Note: If overriding this function, it is advisable to store the file
+     * in the path returned by get_local_path_from_hash as there may be
+     * subsequent uses of the file in the same request.
+     *
+     * @param string $pathname Path to file currently on disk
+     * @param string $contenthash SHA1 hash of content if known (performance only)
+     * @return array (contenthash, filesize, newfile)
+     */
+    public function add_file_from_path($pathname, $contenthash = null) {
+        global $CFG;
+
+        if (!is_readable($pathname)) {
+            throw new file_exception('storedfilecannotread', '', $pathname);
+        }
+
+        $filesize = filesize($pathname);
+        if ($filesize === false) {
+            throw new file_exception('storedfilecannotread', '', $pathname);
+        }
+
+        if (is_null($contenthash)) {
+            $contenthash = sha1_file($pathname);
+        } else if ($CFG->debugdeveloper) {
+            $filehash = sha1_file($pathname);
+            if ($filehash === false) {
+                throw new file_exception('storedfilecannotread', '', $pathname);
+            }
+            if ($filehash !== $contenthash) {
+                // Hopefully this never happens, if yes we need to fix calling code.
+                debugging("Invalid contenthash submitted for file $pathname", DEBUG_DEVELOPER);
+                $contenthash = $filehash;
+            }
+        }
+        if ($contenthash === false) {
+            throw new file_exception('storedfilecannotread', '', $pathname);
+        }
+
+        if ($filesize > 0 and $contenthash === sha1('')) {
+            // Did the file change or is sha1_file() borked for this file?
+            clearstatcache();
+            $contenthash = sha1_file($pathname);
+            $filesize = filesize($pathname);
+
+            if ($contenthash === false or $filesize === false) {
+                throw new file_exception('storedfilecannotread', '', $pathname);
+            }
+            if ($filesize > 0 and $contenthash === sha1('')) {
+                // This is very weird...
+                throw new file_exception('storedfilecannotread', '', $pathname);
+            }
+        }
+
+        $hashpath = $this->get_fulldir_from_hash($contenthash);
+        $hashfile = $this->get_local_path_from_hash($contenthash, false);
+
+        $newfile = true;
+
+        if (file_exists($hashfile)) {
+            if (filesize($hashfile) === $filesize) {
+                return array($contenthash, $filesize, false);
+            }
+            if (sha1_file($hashfile) === $contenthash) {
+                // Jackpot! We have a sha1 collision.
+                mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
+                copy($pathname, "$this->filedir/jackpot/{$contenthash}_1");
+                copy($hashfile, "$this->filedir/jackpot/{$contenthash}_2");
+                throw new file_pool_content_exception($contenthash);
+            }
+            debugging("Replacing invalid content file $contenthash");
+            unlink($hashfile);
+            $newfile = false;
+        }
+
+        if (!is_dir($hashpath)) {
+            if (!mkdir($hashpath, $this->dirpermissions, true)) {
+                // Permission trouble.
+                throw new file_exception('storedfilecannotcreatefiledirs');
+            }
+        }
+
+        // Let's try to prevent some race conditions.
+
+        $prev = ignore_user_abort(true);
+        @unlink($hashfile.'.tmp');
+        if (!copy($pathname, $hashfile.'.tmp')) {
+            // Borked permissions or out of disk space.
+            ignore_user_abort($prev);
+            throw new file_exception('storedfilecannotcreatefile');
+        }
+        if (filesize($hashfile.'.tmp') !== $filesize) {
+            // This should not happen.
+            unlink($hashfile.'.tmp');
+            ignore_user_abort($prev);
+            throw new file_exception('storedfilecannotcreatefile');
+        }
+        rename($hashfile.'.tmp', $hashfile);
+        chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
+        @unlink($hashfile.'.tmp'); // Just in case anything fails in a weird way.
+        ignore_user_abort($prev);
+
+        return array($contenthash, $filesize, $newfile);
+    }
+
+    /**
+     * Add a file with the supplied content to the file system.
+     *
+     * Note: If overriding this function, it is advisable to store the file
+     * in the path returned by get_local_path_from_hash as there may be
+     * subsequent uses of the file in the same request.
+     *
+     * @param string $content file content - binary string
+     * @return array (contenthash, filesize, newfile)
+     */
+    public function add_file_from_string($content) {
+        global $CFG;
+
+        $contenthash = sha1($content);
+        // Binary length.
+        $filesize = strlen($content);
+
+        $hashpath = $this->get_fulldir_from_hash($contenthash);
+        $hashfile = $this->get_local_path_from_hash($contenthash, false);
+
+        $newfile = true;
+
+        if (file_exists($hashfile)) {
+            if (filesize($hashfile) === $filesize) {
+                return array($contenthash, $filesize, false);
+            }
+            if (sha1_file($hashfile) === $contenthash) {
+                // Jackpot! We have a sha1 collision.
+                mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
+                copy($hashfile, "$this->filedir/jackpot/{$contenthash}_1");
+                file_put_contents("$this->filedir/jackpot/{$contenthash}_2", $content);
+                throw new file_pool_content_exception($contenthash);
+            }
+            debugging("Replacing invalid content file $contenthash");
+            unlink($hashfile);
+            $newfile = false;
+        }
+
+        if (!is_dir($hashpath)) {
+            if (!mkdir($hashpath, $this->dirpermissions, true)) {
+                // Permission trouble.
+                throw new file_exception('storedfilecannotcreatefiledirs');
+            }
+        }
+
+        // Hopefully this works around most potential race conditions.
+
+        $prev = ignore_user_abort(true);
+
+        if (!empty($CFG->preventfilelocking)) {
+            $newsize = file_put_contents($hashfile.'.tmp', $content);
+        } else {
+            $newsize = file_put_contents($hashfile.'.tmp', $content, LOCK_EX);
+        }
+
+        if ($newsize === false) {
+            // Borked permissions most likely.
+            ignore_user_abort($prev);
+            throw new file_exception('storedfilecannotcreatefile');
+        }
+        if (filesize($hashfile.'.tmp') !== $filesize) {
+            // Out of disk space?
+            unlink($hashfile.'.tmp');
+            ignore_user_abort($prev);
+            throw new file_exception('storedfilecannotcreatefile');
+        }
+        rename($hashfile.'.tmp', $hashfile);
+        chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
+        @unlink($hashfile.'.tmp'); // Just in case anything fails in a weird way.
+        ignore_user_abort($prev);
+
+        return array($contenthash, $filesize, $newfile);
+    }
+
+}
diff --git a/lib/filestorage/stored_file.php b/lib/filestorage/stored_file.php
index b115f965673..7beb9fc92ca 100644
--- a/lib/filestorage/stored_file.php
+++ b/lib/filestorage/stored_file.php
@@ -26,6 +26,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->dirroot . '/lib/filestorage/file_progress.php');
+require_once($CFG->dirroot . '/lib/filestorage/file_system.php');
 
 /**
  * Class representing local files stored in a sha1 file pool.
@@ -44,10 +45,10 @@ class stored_file {
     private $fs;
     /** @var stdClass record from the files table left join files_reference table */
     private $file_record;
-    /** @var string location of content files */
-    private $filedir;
     /** @var repository repository plugin instance */
     private $repository;
+    /** @var file_system filesystem instance */
+    private $filesystem;
 
     /**
      * @var int Indicates a file handle of the type returned by fopen.
@@ -65,13 +66,12 @@ class stored_file {
      *
      * @param file_storage $fs file  storage instance
      * @param stdClass $file_record description of file
-     * @param string $filedir location of file directory with sh1 named content files
+     * @param string $deprecated
      */
-    public function __construct(file_storage $fs, stdClass $file_record, $filedir) {
+    public function __construct(file_storage $fs, stdClass $file_record, $deprecated = null) {
         global $DB, $CFG;
         $this->fs          = $fs;
         $this->file_record = clone($file_record); // prevent modifications
-        $this->filedir     = $filedir; // keep secret, do not expose!
 
         if (!empty($file_record->repositoryid)) {
             require_once("$CFG->dirroot/repository/lib.php");
@@ -89,6 +89,8 @@ class stored_file {
                 $this->file_record->$key = null;
             }
         }
+
+        $this->filesystem = $fs->get_file_system();
     }
 
     /**
@@ -179,15 +181,7 @@ class stored_file {
             }
         }
         // Validate mimetype field
-        // we don't use {@link stored_file::get_content_file_location()} here becaues it will try to update file_record
-        $pathname = $this->get_pathname_by_contenthash();
-        // try to recover the content from trash
-        if (!is_readable($pathname)) {
-            if (!$this->fs->try_content_recovery($this) or !is_readable($pathname)) {
-                throw new file_exception('storedfilecannotread', '', $pathname);
-            }
-        }
-        $mimetype = $this->fs->mimetype($pathname, $this->file_record->filename);
+        $mimetype = $this->filesystem->mimetype_from_storedfile($this);
         $this->file_record->mimetype = $mimetype;
 
         $DB->update_record('files', $this->file_record);
@@ -255,8 +249,8 @@ class stored_file {
         }
 
         $filerecord = new stdClass;
-        $contenthash = $newfile->get_contenthash();
-        if ($this->fs->content_exists($contenthash)) {
+        if ($this->filesystem->is_file_readable_remotely_by_storedfile($newfile)) {
+            $contenthash = $newfile->get_contenthash();
             $filerecord->contenthash = $contenthash;
         } else {
             throw new file_exception('storedfileproblem', 'Invalid contenthash, content must be already in filepool', $contenthash);
@@ -357,40 +351,10 @@ class stored_file {
         }
 
         // Move pool file to trash if content not needed any more.
-        $this->fs->deleted_file_cleanup($this->file_record->contenthash);
+        $this->filesystem->remove_file($this->file_record->contenthash);
         return true; // BC only
     }
 
-    /**
-     * Get file pathname by contenthash
-     *
-     * NOTE, this function is not calling sync_external_file, it assume the contenthash is current
-     * Protected - developers must not gain direct access to this function.
-     *
-     * @return string full path to pool file with file content
-     */
-    protected function get_pathname_by_contenthash() {
-        // Detect is local file or not.
-        $contenthash = $this->file_record->contenthash;
-        $l1 = $contenthash[0].$contenthash[1];
-        $l2 = $contenthash[2].$contenthash[3];
-        return "$this->filedir/$l1/$l2/$contenthash";
-    }
-
-    /**
-     * Get file pathname by given contenthash, this method will try to sync files
-     *
-     * Protected - developers must not gain direct access to this function.
-     *
-     * NOTE: do not make this public, we must not modify or delete the pool files directly! ;-)
-     *
-     * @return string full path to pool file with file content
-     **/
-    protected function get_content_file_location() {
-        $this->sync_external_file();
-        return $this->get_pathname_by_contenthash();
-    }
-
     /**
     * adds this file path to a curl request (POST only)
     *
@@ -399,13 +363,7 @@ class stored_file {
     * @return void
     */
     public function add_to_curl_request(&$curlrequest, $key) {
-        if (function_exists('curl_file_create')) {
-            // As of PHP 5.5, the usage of the @filename API for file uploading is deprecated.
-            $value = curl_file_create($this->get_content_file_location());
-        } else {
-            $value = '@' . $this->get_content_file_location();
-        }
-        $curlrequest->_tmp_file_post_params[$key] = $value;
+        return $this->filesystem->add_to_curl_request($this, $curlrequest, $key);
     }
 
     /**
@@ -417,35 +375,14 @@ class stored_file {
      * @return resource file handle
      */
     public function get_content_file_handle($type = self::FILE_HANDLE_FOPEN) {
-        $path = $this->get_content_file_location();
-        if (!is_readable($path)) {
-            if (!$this->fs->try_content_recovery($this) or !is_readable($path)) {
-                throw new file_exception('storedfilecannotread', '', $path);
-            }
-        }
-        switch ($type) {
-            case self::FILE_HANDLE_FOPEN:
-                // Binary reading.
-                return fopen($path, 'rb');
-            case self::FILE_HANDLE_GZOPEN:
-                // Binary reading of file in gz format.
-                return gzopen($path, 'rb');
-            default:
-                throw new coding_exception('Unexpected file handle type');
-        }
+        return $this->filesystem->get_content_file_handle($this, $type);
     }
 
     /**
      * Dumps file content to page.
      */
     public function readfile() {
-        $path = $this->get_content_file_location();
-        if (!is_readable($path)) {
-            if (!$this->fs->try_content_recovery($this) or !is_readable($path)) {
-                throw new file_exception('storedfilecannotread', '', $path);
-            }
-        }
-        readfile_allow_large($path, $this->get_filesize());
+        return $this->filesystem->readfile($this);
     }
 
     /**
@@ -454,13 +391,7 @@ class stored_file {
      * @return string content
      */
     public function get_content() {
-        $path = $this->get_content_file_location();
-        if (!is_readable($path)) {
-            if (!$this->fs->try_content_recovery($this) or !is_readable($path)) {
-                throw new file_exception('storedfilecannotread', '', $path);
-            }
-        }
-        return file_get_contents($this->get_content_file_location());
+        return $this->filesystem->get_content($this);
     }
 
     /**
@@ -470,13 +401,7 @@ class stored_file {
      * @return bool success
      */
     public function copy_content_to($pathname) {
-        $path = $this->get_content_file_location();
-        if (!is_readable($path)) {
-            if (!$this->fs->try_content_recovery($this) or !is_readable($path)) {
-                throw new file_exception('storedfilecannotread', '', $path);
-            }
-        }
-        return copy($path, $pathname);
+        return $this->filesystem->copy_content_from_storedfile($this, $pathname);
     }
 
     /**
@@ -509,8 +434,7 @@ class stored_file {
      * @return array of file infos
      */
     public function list_files(file_packer $packer) {
-        $archivefile = $this->get_content_file_location();
-        return $packer->list_files($archivefile);
+        return $this->filesystem->list_files($this, $packer);
     }
 
     /**
@@ -523,8 +447,7 @@ class stored_file {
      */
     public function extract_to_pathname(file_packer $packer, $pathname,
             file_progress $progress = null) {
-        $archivefile = $this->get_content_file_location();
-        return $packer->extract_to_pathname($archivefile, $pathname, null, $progress);
+        return $this->filesystem->extract_to_pathname($this, $packer, $pathname, $progress);
     }
 
     /**
@@ -542,9 +465,9 @@ class stored_file {
      */
     public function extract_to_storage(file_packer $packer, $contextid,
             $component, $filearea, $itemid, $pathbase, $userid = null, file_progress $progress = null) {
-        $archivefile = $this->get_content_file_location();
-        return $packer->extract_to_storage($archivefile, $contextid,
-                $component, $filearea, $itemid, $pathbase, $userid, $progress);
+
+        return $this->filesystem->extract_to_storage($this, $packer, $contextid, $component, $filearea,
+                $itemid, $pathbase, $userid, $progress);
     }
 
     /**
@@ -555,15 +478,7 @@ class stored_file {
      * @return bool success
      */
     public function archive_file(file_archive $filearch, $archivepath) {
-        if ($this->is_directory()) {
-            return $filearch->add_directory($archivepath);
-        } else {
-            $path = $this->get_content_file_location();
-            if (!is_readable($path)) {
-                return false;
-            }
-            return $filearch->add_file_from_pathname($archivepath, $path);
-        }
+        return $this->filesystem->add_storedfile_to_archive($this, $filearch, $archivepath);
     }
 
     /**
@@ -573,22 +488,7 @@ class stored_file {
      * @return mixed array with width, height and mimetype; false if not an image
      */
     public function get_imageinfo() {
-        $path = $this->get_content_file_location();
-        if (!is_readable($path)) {
-            if (!$this->fs->try_content_recovery($this) or !is_readable($path)) {
-                throw new file_exception('storedfilecannotread', '', $path);
-            }
-        }
-        $mimetype = $this->get_mimetype();
-        if (!preg_match('|^image/|', $mimetype) || !filesize($path) || !($imageinfo = getimagesize($path))) {
-            return false;
-        }
-        $image = array('width'=>$imageinfo[0], 'height'=>$imageinfo[1], 'mimetype'=>image_type_to_mime_type($imageinfo[2]));
-        if (empty($image['width']) or empty($image['height']) or empty($image['mimetype'])) {
-            // gd can not parse it, sorry
-            return false;
-        }
-        return $image;
+        return $this->filesystem->get_imageinfo($this);
     }
 
     /**
@@ -985,7 +885,7 @@ class stored_file {
             $this->file_record->timemodified = $timemodified;
         }
         if (isset($oldcontenthash)) {
-            $this->fs->deleted_file_cleanup($oldcontenthash);
+            $this->filesystem->remove_file($oldcontenthash);
         }
     }
 
@@ -1051,14 +951,16 @@ class stored_file {
             return false;
         }
 
+        $content = $this->get_content();
+
         // Fetch the image information for this image.
-        $imageinfo = @getimagesizefromstring($this->get_content());
+        $imageinfo = @getimagesizefromstring($content);
         if (empty($imageinfo)) {
             return false;
         }
 
         // Create a new image from the file.
-        $original = @imagecreatefromstring($this->get_content());
+        $original = @imagecreatefromstring($content);
 
         // Generate the thumbnail.
         return generate_image_thumbnail_from_image($original, $imageinfo, $width, $height);
diff --git a/lib/filestorage/tests/file_storage_test.php b/lib/filestorage/tests/file_storage_test.php
index f453f345171..8310f67af63 100644
--- a/lib/filestorage/tests/file_storage_test.php
+++ b/lib/filestorage/tests/file_storage_test.php
@@ -64,7 +64,10 @@ class core_files_file_storage_testcase extends advanced_testcase {
 
         $this->assertTrue($DB->record_exists('files', array('pathnamehash'=>$pathhash)));
 
-        $location = test_stored_file_inspection::get_pretected_pathname($file);
+        $method = new ReflectionMethod('file_system', 'get_local_path_from_storedfile');
+        $method->setAccessible(true);
+        $filesystem = $fs->get_file_system();
+        $location = $method->invokeArgs($filesystem, array($file, true));
 
         $this->assertFileExists($location);
 
@@ -133,7 +136,10 @@ class core_files_file_storage_testcase extends advanced_testcase {
 
         $this->assertTrue($DB->record_exists('files', array('pathnamehash'=>$pathhash)));
 
-        $location = test_stored_file_inspection::get_pretected_pathname($file);
+        $method = new ReflectionMethod('file_system', 'get_local_path_from_storedfile');
+        $method->setAccessible(true);
+        $filesystem = $fs->get_file_system();
+        $location = $method->invokeArgs($filesystem, array($file, true));
 
         $this->assertFileExists($location);
 
@@ -1748,6 +1754,53 @@ class core_files_file_storage_testcase extends advanced_testcase {
         $this->setExpectedException('coding_exception');
         $fs->get_unused_filename($contextid, $component, $filearea, $itemid, $filepath, '');
     }
+
+    /**
+     * Test that mimetype_from_file returns appropriate output when the
+     * file could not be found.
+     */
+    public function test_mimetype_not_found() {
+        $mimetype = file_storage::mimetype('/path/to/nonexistent/file');
+        $this->assertEquals('document/unknown', $mimetype);
+    }
+
+    /**
+     * Test that mimetype_from_file returns appropriate output for a known
+     * file.
+     *
+     * Note: this is not intended to check that functions outside of this
+     * file works. It is intended to validate the codepath contains no
+     * errors and behaves as expected.
+     */
+    public function test_mimetype_known() {
+        $filepath = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'testimage.jpg';
+        $mimetype = file_storage::mimetype_from_file($filepath);
+        $this->assertEquals('image/jpeg', $mimetype);
+    }
+
+    /**
+     * Test that mimetype_from_file returns appropriate output when the
+     * file could not be found.
+     */
+    public function test_mimetype_from_file_not_found() {
+        $mimetype = file_storage::mimetype_from_file('/path/to/nonexistent/file');
+        $this->assertEquals('document/unknown', $mimetype);
+    }
+
+    /**
+     * Test that mimetype_from_file returns appropriate output for a known
+     * file.
+     *
+     * Note: this is not intended to check that functions outside of this
+     * file works. It is intended to validate the codepath contains no
+     * errors and behaves as expected.
+     */
+    public function test_mimetype_from_file_known() {
+        $filepath = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'testimage.jpg';
+        $mimetype = file_storage::mimetype_from_file($filepath);
+        $this->assertEquals('image/jpeg', $mimetype);
+    }
+
 }
 
 class test_stored_file_inspection extends stored_file {
diff --git a/lib/filestorage/tests/file_system_filedir_test.php b/lib/filestorage/tests/file_system_filedir_test.php
new file mode 100644
index 00000000000..1f65099cb9d
--- /dev/null
+++ b/lib/filestorage/tests/file_system_filedir_test.php
@@ -0,0 +1,1063 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for file_system_filedir.
+ *
+ * @package   core_files
+ * @category  phpunit
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/filestorage/file_system.php');
+require_once($CFG->libdir . '/filestorage/file_system_filedir.php');
+
+/**
+ * Unit tests for file_system_filedir.
+ *
+ * @package   core_files
+ * @category  files
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_files_file_system_filedir_testcase extends advanced_testcase {
+
+    /**
+     * Shared test setUp.
+     */
+    public function setUp() {
+        // Reset the file storage so that subsequent fetches to get_file_storage are called after
+        // configuration is prepared.
+        get_file_storage(true);
+    }
+
+    /**
+     * Shared teset tearDown.
+     */
+    public function tearDown() {
+        // Reset the file storage so that subsequent tests will use the standard file storage.
+        get_file_storage(true);
+    }
+
+    /**
+     * Helper function to help setup and configure the virtual file system stream.
+     *
+     * @param   array $filedir Directory structure and content of the filedir
+     * @param   array $trashdir Directory structure and content of the sourcedir
+     * @param   array $sourcedir Directory structure and content of a directory used for source files for tests
+     * @return  \org\bovigo\vfs\vfsStream
+     */
+    protected function setup_vfile_root($filedir = [], $trashdir = [], $sourcedir = null) {
+        global $CFG;
+        $this->resetAfterTest();
+
+        $content = [];
+        if ($filedir !== null) {
+            $content['filedir'] = $filedir;
+        }
+
+        if ($trashdir !== null) {
+            $content['trashdir'] = $trashdir;
+        }
+
+        if ($sourcedir !== null) {
+            $content['sourcedir'] = $sourcedir;
+        }
+
+        $vfileroot = \org\bovigo\vfs\vfsStream::setup('root', null, $content);
+
+        $CFG->filedir = \org\bovigo\vfs\vfsStream::url('root/filedir');
+        $CFG->trashdir = \org\bovigo\vfs\vfsStream::url('root/trashdir');
+
+        return $vfileroot;
+    }
+
+    /**
+     * Helper to create a stored file objectw with the given supplied content.
+     *
+     * @param   string  $filecontent The content of the mocked file
+     * @param   string  $filename The file name to use in the stored_file
+     * @param   array   $mockedmethods A list of methods you intend to override
+     *                  If no methods are specified, only abstract functions are mocked.
+     * @return stored_file
+     */
+    protected function get_stored_file($filecontent, $filename = null, $mockedmethods = null) {
+        $contenthash = sha1($filecontent);
+        if (empty($filename)) {
+            $filename = $contenthash;
+        }
+
+        $file = $this->getMockBuilder(stored_file::class)
+            ->setMethods($mockedmethods)
+            ->setConstructorArgs([
+                get_file_storage(),
+                (object) [
+                    'contenthash' => $contenthash,
+                    'filesize' => strlen($filecontent),
+                    'filename' => $filename,
+                ]
+            ])
+            ->getMock();
+
+        return $file;
+    }
+
+    /**
+     * Get a testable mock of the file_system_filedir class.
+     *
+     * @param   array   $mockedmethods A list of methods you intend to override
+     *                  If no methods are specified, only abstract functions are mocked.
+     * @return file_system
+     */
+    protected function get_testable_mock($mockedmethods = []) {
+        $fs = $this->getMockBuilder(file_system_filedir::class)
+            ->setMethods($mockedmethods)
+            ->getMock();
+
+        return $fs;
+    }
+
+    /**
+     * Ensure that an appropriate error is shown when the filedir directory
+     * is not writable.
+     */
+    public function test_readonly_filesystem_filedir() {
+        $this->resetAfterTest();
+
+        // Setup the filedir but remove permissions.
+        $vfileroot = $this->setup_vfile_root(null);
+
+        // Make the target path readonly.
+        $vfileroot->chmod(0444)
+            ->chown(\org\bovigo\vfs\vfsStream::OWNER_USER_2);
+
+        // This should generate an exception.
+        $this->setExpectedExceptionRegexp('file_exception',
+            '/Can not create local file pool directories, please verify permissions in dataroot./');
+
+        new file_system_filedir();
+    }
+
+    /**
+     * Ensure that an appropriate error is shown when the trash directory
+     * is not writable.
+     */
+    public function test_readonly_filesystem_trashdir() {
+        $this->resetAfterTest();
+
+        // Setup the trashdir but remove permissions.
+        $vfileroot = $this->setup_vfile_root([], null);
+
+        // Make the target path readonly.
+        $vfileroot->chmod(0444)
+            ->chown(\org\bovigo\vfs\vfsStream::OWNER_USER_2);
+
+        // This should generate an exception.
+        $this->setExpectedExceptionRegexp('file_exception',
+            '/Can not create local file pool directories, please verify permissions in dataroot./');
+
+        new file_system_filedir();
+    }
+
+    /**
+     * Test that the standard Moodle warning message is put into the filedir.
+     */
+    public function test_warnings_put_in_place() {
+        $this->resetAfterTest();
+
+        $vfileroot = $this->setup_vfile_root(null);
+
+        new file_system_filedir();
+        $this->assertTrue($vfileroot->hasChild('filedir/warning.txt'));
+        $this->assertEquals(
+            'This directory contains the content of uploaded files and is controlled by Moodle code. ' .
+                'Do not manually move, change or rename any of the files and subdirectories here.',
+            $vfileroot->getChild('filedir/warning.txt')->getContent()
+        );
+    }
+
+    /**
+     * Ensure that the default implementation of get_remote_path_from_hash
+     * simply calls get_local_path_from_hash.
+     */
+    public function test_get_remote_path_from_hash() {
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $expectedresult = (object) [];
+
+        $fs = $this->get_testable_mock([
+            'get_local_path_from_hash',
+        ]);
+
+        $fs->expects($this->once())
+            ->method('get_local_path_from_hash')
+            ->with($this->equalTo($contenthash), $this->equalTo(false))
+            ->willReturn($expectedresult);
+
+        $method = new ReflectionMethod(file_system_filedir::class, 'get_remote_path_from_hash');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, [$contenthash]);
+
+        $this->assertEquals($expectedresult, $result);
+    }
+
+    /**
+     * Test the stock implementation of get_local_path_from_storedfile_with_recovery with no file found and
+     * a failed recovery.
+     */
+    public function test_get_local_path_from_storedfile_with_recovery() {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent);
+        $fs = $this->get_testable_mock([
+            'get_local_path_from_hash',
+            'recover_file',
+        ]);
+        $filepath = '/path/to/nonexistent/file';
+
+        $fs->method('get_local_path_from_hash')
+            ->willReturn($filepath);
+
+        $fs->expects($this->once())
+            ->method('recover_file')
+            ->with($this->equalTo($file));
+
+        $file = $this->get_stored_file('example content');
+        $method = new ReflectionMethod(file_system_filedir::class, 'get_local_path_from_storedfile');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($file, true));
+
+        $this->assertEquals($filepath, $result);
+    }
+
+    /**
+     * Test the stock implementation of get_local_path_from_storedfile_with_recovery with no file found and
+     * a failed recovery.
+     */
+    public function test_get_local_path_from_storedfile_without_recovery() {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent);
+        $fs = $this->get_testable_mock([
+            'get_local_path_from_hash',
+            'recover_file',
+        ]);
+        $filepath = '/path/to/nonexistent/file';
+
+        $fs->method('get_local_path_from_hash')
+            ->willReturn($filepath);
+
+        $fs->expects($this->never())
+            ->method('recover_file');
+
+        $file = $this->get_stored_file('example content');
+        $method = new ReflectionMethod(file_system_filedir::class, 'get_local_path_from_storedfile');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($file, false));
+
+        $this->assertEquals($filepath, $result);
+    }
+
+    /**
+     * Test that the correct path is generated for the supplied content
+     * hashes.
+     *
+     * @dataProvider contenthash_dataprovider
+     * @param   string  $hash contenthash to test
+     * @param   string  $hashdir Expected format of content directory
+     */
+    public function test_get_fulldir_from_hash($hash, $hashdir) {
+        global $CFG;
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod(file_system_filedir::class, 'get_fulldir_from_hash');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($hash));
+
+        $expectedpath = sprintf('%s/filedir/%s', $CFG->dataroot, $hashdir);
+        $this->assertEquals($expectedpath, $result);
+    }
+
+    /**
+     * Test that the correct path is generated for the supplied content
+     * hashes when used with a stored_file.
+     *
+     * @dataProvider contenthash_dataprovider
+     * @param   string  $hash contenthash to test
+     * @param   string  $hashdir Expected format of content directory
+     */
+    public function test_get_fulldir_from_storedfile($hash, $hashdir) {
+        global $CFG;
+
+        $file = $this->getMockBuilder('stored_file')
+            ->disableOriginalConstructor()
+            ->setMethods([
+                'sync_external_file',
+                'get_contenthash',
+            ])
+            ->getMock();
+
+        $file->method('get_contenthash')->willReturn($hash);
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod('file_system_filedir', 'get_fulldir_from_storedfile');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($file));
+
+        $expectedpath = sprintf('%s/filedir/%s', $CFG->dataroot, $hashdir);
+        $this->assertEquals($expectedpath, $result);
+    }
+
+    /**
+     * Test that the correct content directory is generated for the supplied
+     * content hashes.
+     *
+     * @dataProvider contenthash_dataprovider
+     * @param   string  $hash contenthash to test
+     * @param   string  $hashdir Expected format of content directory
+     */
+    public function test_get_contentdir_from_hash($hash, $hashdir) {
+        $method = new ReflectionMethod(file_system_filedir::class, 'get_contentdir_from_hash');
+        $method->setAccessible(true);
+
+        $fs = new file_system_filedir();
+        $result = $method->invokeArgs($fs, array($hash));
+
+        $this->assertEquals($hashdir, $result);
+    }
+
+    /**
+     * Test that the correct content path is generated for the supplied
+     * content hashes.
+     *
+     * @dataProvider contenthash_dataprovider
+     * @param   string  $hash contenthash to test
+     * @param   string  $hashdir Expected format of content directory
+     */
+    public function test_get_contentpath_from_hash($hash, $hashdir) {
+        $method = new ReflectionMethod(file_system_filedir::class, 'get_contentpath_from_hash');
+        $method->setAccessible(true);
+
+        $fs = new file_system_filedir();
+        $result = $method->invokeArgs($fs, array($hash));
+
+        $expectedpath = sprintf('%s/%s', $hashdir, $hash);
+        $this->assertEquals($expectedpath, $result);
+    }
+
+    /**
+     * Test that the correct trash path is generated for the supplied
+     * content hashes.
+     *
+     * @dataProvider contenthash_dataprovider
+     * @param   string  $hash contenthash to test
+     * @param   string  $hashdir Expected format of content directory
+     */
+    public function test_get_trash_fullpath_from_hash($hash, $hashdir) {
+        global $CFG;
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod(file_system_filedir::class, 'get_trash_fullpath_from_hash');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($hash));
+
+        $expectedpath = sprintf('%s/trashdir/%s/%s', $CFG->dataroot, $hashdir, $hash);
+        $this->assertEquals($expectedpath, $result);
+    }
+
+    /**
+     * Test that the correct trash directory is generated for the supplied
+     * content hashes.
+     *
+     * @dataProvider contenthash_dataprovider
+     * @param   string  $hash contenthash to test
+     * @param   string  $hashdir Expected format of content directory
+     */
+    public function test_get_trash_fulldir_from_hash($hash, $hashdir) {
+        global $CFG;
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod(file_system_filedir::class, 'get_trash_fulldir_from_hash');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($hash));
+
+        $expectedpath = sprintf('%s/trashdir/%s', $CFG->dataroot, $hashdir);
+        $this->assertEquals($expectedpath, $result);
+    }
+
+    /**
+     * Ensure that copying a file to a target from a stored_file works as anticipated.
+     */
+    public function test_copy_content_from_storedfile() {
+        $this->resetAfterTest();
+        global $CFG;
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $filedircontent = [
+            $contenthash => $filecontent,
+        ];
+        $vfileroot = $this->setup_vfile_root($filedircontent, [], []);
+
+        $fs = $this->getMockBuilder(file_system_filedir::class)
+            ->disableOriginalConstructor()
+            ->setMethods([
+                'get_local_path_from_storedfile',
+            ])
+            ->getMock();
+
+        $file = $this->getMockBuilder(stored_file::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $sourcefile = \org\bovigo\vfs\vfsStream::url('root/filedir/' . $contenthash);
+        $fs->method('get_local_path_from_storedfile')->willReturn($sourcefile);
+
+        $targetfile = \org\bovigo\vfs\vfsStream::url('root/targetfile');
+        $CFG->preventfilelocking = true;
+        $result = $fs->copy_content_from_storedfile($file, $targetfile);
+
+        $this->assertTrue($result);
+        $this->assertEquals($filecontent, $vfileroot->getChild('targetfile')->getContent());
+    }
+
+    /**
+     * Ensure that content recovery works.
+     */
+    public function test_recover_file() {
+        $this->resetAfterTest();
+
+        // Setup the filedir.
+        // This contains a virtual file which has a cache mismatch.
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $trashdircontent = [
+            '0f' => [
+                'f3' => [
+                    $contenthash => $filecontent,
+                ],
+            ],
+        ];
+
+        $vfileroot = $this->setup_vfile_root([], $trashdircontent);
+
+        $file = new stored_file(get_file_storage(), (object) [
+            'contenthash' => $contenthash,
+            'filesize' => strlen($filecontent),
+        ]);
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod(file_system_filedir::class, 'recover_file');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($file));
+
+        // Test the output.
+        $this->assertTrue($result);
+
+        $this->assertEquals($filecontent, $vfileroot->getChild('filedir/0f/f3/' . $contenthash)->getContent());
+
+    }
+
+    /**
+     * Ensure that content recovery works.
+     */
+    public function test_recover_file_already_present() {
+        $this->resetAfterTest();
+
+        // Setup the filedir.
+        // This contains a virtual file which has a cache mismatch.
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $filedircontent = $trashdircontent = [
+            '0f' => [
+                'f3' => [
+                    $contenthash => $filecontent,
+                ],
+            ],
+        ];
+
+        $vfileroot = $this->setup_vfile_root($filedircontent, $trashdircontent);
+
+        $file = new stored_file(get_file_storage(), (object) [
+            'contenthash' => $contenthash,
+            'filesize' => strlen($filecontent),
+        ]);
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod(file_system_filedir::class, 'recover_file');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($file));
+
+        // Test the output.
+        $this->assertTrue($result);
+
+        $this->assertEquals($filecontent, $vfileroot->getChild('filedir/0f/f3/' . $contenthash)->getContent());
+    }
+
+    /**
+     * Ensure that content recovery works.
+     */
+    public function test_recover_file_size_mismatch() {
+        $this->resetAfterTest();
+
+        // Setup the filedir.
+        // This contains a virtual file which has a cache mismatch.
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $trashdircontent = [
+            '0f' => [
+                'f3' => [
+                    $contenthash => $filecontent,
+                ],
+            ],
+        ];
+        $vfileroot = $this->setup_vfile_root([], $trashdircontent);
+
+        $file = new stored_file(get_file_storage(), (object) [
+            'contenthash' => $contenthash,
+            'filesize' => strlen($filecontent) + 1,
+        ]);
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod(file_system_filedir::class, 'recover_file');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($file));
+
+        // Test the output.
+        $this->assertFalse($result);
+        $this->assertFalse($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
+    }
+
+    /**
+     * Ensure that content recovery works.
+     */
+    public function test_recover_file_has_mismatch() {
+        $this->resetAfterTest();
+
+        // Setup the filedir.
+        // This contains a virtual file which has a cache mismatch.
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $trashdircontent = [
+            '0f' => [
+                'f3' => [
+                    $contenthash => $filecontent,
+                ],
+            ],
+        ];
+        $vfileroot = $this->setup_vfile_root([], $trashdircontent);
+
+        $file = new stored_file(get_file_storage(), (object) [
+            'contenthash' => $contenthash . " different",
+            'filesize' => strlen($filecontent),
+        ]);
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod(file_system_filedir::class, 'recover_file');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($file));
+
+        // Test the output.
+        $this->assertFalse($result);
+        $this->assertFalse($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
+    }
+
+    /**
+     * Ensure that content recovery works when the content file is in the
+     * alt trash directory.
+     */
+    public function test_recover_file_alttrash() {
+        $this->resetAfterTest();
+
+        // Setup the filedir.
+        // This contains a virtual file which has a cache mismatch.
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $trashdircontent = [
+            $contenthash => $filecontent,
+        ];
+        $vfileroot = $this->setup_vfile_root([], $trashdircontent);
+
+        $file = new stored_file(get_file_storage(), (object) [
+            'contenthash' => $contenthash,
+            'filesize' => strlen($filecontent),
+        ]);
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod(file_system_filedir::class, 'recover_file');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($file));
+
+        // Test the output.
+        $this->assertTrue($result);
+
+        $this->assertEquals($filecontent, $vfileroot->getChild('filedir/0f/f3/' . $contenthash)->getContent());
+    }
+
+    /**
+     * Test that an appropriate error message is generated when adding a
+     * file to the pool when the pool directory structure is not writable.
+     */
+    public function test_recover_file_contentdir_readonly() {
+        $this->resetAfterTest();
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $filedircontent = [
+            '0f' => [],
+        ];
+        $trashdircontent = [
+            $contenthash => $filecontent,
+        ];
+        $vfileroot = $this->setup_vfile_root($filedircontent, $trashdircontent);
+
+        // Make the target path readonly.
+        $vfileroot->getChild('filedir/0f')
+            ->chmod(0444)
+            ->chown(\org\bovigo\vfs\vfsStream::OWNER_USER_2);
+
+        $file = new stored_file(get_file_storage(), (object) [
+            'contenthash' => $contenthash,
+            'filesize' => strlen($filecontent),
+        ]);
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod(file_system_filedir::class, 'recover_file');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array($file));
+
+        // Test the output.
+        $this->assertFalse($result);
+    }
+
+    /**
+     * Test adding a file to the pool.
+     */
+    public function test_add_file_from_path() {
+        $this->resetAfterTest();
+        global $CFG;
+
+        // Setup the filedir.
+        // This contains a virtual file which has a cache mismatch.
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $sourcedircontent = [
+            'file' => $filecontent,
+        ];
+
+        $vfileroot = $this->setup_vfile_root([], [], $sourcedircontent);
+
+        // Note, the vfs file system does not support locks - prevent file locking here.
+        $CFG->preventfilelocking = true;
+
+        // Attempt to add the file to the file pool.
+        $fs = new file_system_filedir();
+        $sourcefile = \org\bovigo\vfs\vfsStream::url('root/sourcedir/file');
+        $result = $fs->add_file_from_path($sourcefile);
+
+        // Test the output.
+        $this->assertEquals($contenthash, $result[0]);
+        $this->assertEquals(core_text::strlen($filecontent), $result[1]);
+        $this->assertTrue($result[2]);
+
+        $this->assertEquals($filecontent, $vfileroot->getChild('filedir/0f/f3/' . $contenthash)->getContent());
+    }
+
+    /**
+     * Test that an appropriate error message is generated when adding an
+     * unavailable file to the pool is attempted.
+     */
+    public function test_add_file_from_path_file_unavailable() {
+        $this->resetAfterTest();
+
+        // Setup the filedir.
+        $vfileroot = $this->setup_vfile_root();
+
+        $this->setExpectedExceptionRegexp('file_exception',
+            '/Cannot read file\. Either the file does not exist or there is a permission problem\./');
+
+        $fs = new file_system_filedir();
+        $fs->add_file_from_path(\org\bovigo\vfs\vfsStream::url('filedir/file'));
+    }
+
+    /**
+     * Test that an appropriate error message is generated when specifying
+     * the wrong contenthash when adding a file to the pool.
+     */
+    public function test_add_file_from_path_mismatched_hash() {
+        $this->resetAfterTest();
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $sourcedir = [
+            'file' => $filecontent,
+        ];
+        $vfileroot = $this->setup_vfile_root([], [], $sourcedir);
+
+        $fs = new file_system_filedir();
+        $filepath = \org\bovigo\vfs\vfsStream::url('root/sourcedir/file');
+        $fs->add_file_from_path($filepath, 'eee4943847a35a4b6942c6f96daafde06bcfdfab');
+        $this->assertDebuggingCalled("Invalid contenthash submitted for file $filepath");
+    }
+
+    /**
+     * Test that an appropriate error message is generated when an existing
+     * file in the pool has the wrong contenthash
+     */
+    public function test_add_file_from_path_existing_content_invalid() {
+        $this->resetAfterTest();
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $filedircontent = [
+            '0f' => [
+                'f3' => [
+                    // This contains a virtual file which has a cache mismatch.
+                    '0ff30941ca5acd879fd809e8c937d9f9e6dd1615' => 'different example content',
+                ],
+            ],
+        ];
+        $sourcedir = [
+            'file' => $filecontent,
+        ];
+        $vfileroot = $this->setup_vfile_root($filedircontent, [], $sourcedir);
+
+        // Check that we hit the jackpot.
+        $fs = new file_system_filedir();
+        $filepath = \org\bovigo\vfs\vfsStream::url('root/sourcedir/file');
+        $result = $fs->add_file_from_path($filepath);
+
+        // We provided a bad hash. Check that the file was replaced.
+        $this->assertDebuggingCalled("Replacing invalid content file $contenthash");
+
+        // Test the output.
+        $this->assertEquals($contenthash, $result[0]);
+        $this->assertEquals(core_text::strlen($filecontent), $result[1]);
+        $this->assertFalse($result[2]);
+
+        // Fetch the new file structure.
+        $structure = \org\bovigo\vfs\vfsStream::inspect(
+            new \org\bovigo\vfs\visitor\vfsStreamStructureVisitor()
+        )->getStructure();
+
+        $this->assertEquals($filecontent, $structure['root']['filedir']['0f']['f3'][$contenthash]);
+    }
+
+    /**
+     * Test that an appropriate error message is generated when adding a
+     * file to the pool when the pool directory structure is not writable.
+     */
+    public function test_add_file_from_path_existing_cannot_write_hashpath() {
+        $this->resetAfterTest();
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $filedircontent = [
+            '0f' => [],
+        ];
+        $sourcedir = [
+            'file' => $filecontent,
+        ];
+        $vfileroot = $this->setup_vfile_root($filedircontent, [], $sourcedir);
+
+        // Make the target path readonly.
+        $vfileroot->getChild('filedir/0f')
+            ->chmod(0444)
+            ->chown(\org\bovigo\vfs\vfsStream::OWNER_USER_2);
+
+        $this->setExpectedException(
+            'file_exception',
+            "Can not create local file pool directories, please verify permissions in dataroot."
+        );
+
+        // Attempt to add the file to the file pool.
+        $fs = new file_system_filedir();
+        $sourcefile = \org\bovigo\vfs\vfsStream::url('root/sourcedir/file');
+        $fs->add_file_from_path($sourcefile);
+    }
+
+    /**
+     * Test adding a string to the pool.
+     */
+    public function test_add_file_from_string() {
+        $this->resetAfterTest();
+        global $CFG;
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $vfileroot = $this->setup_vfile_root();
+
+        // Note, the vfs file system does not support locks - prevent file locking here.
+        $CFG->preventfilelocking = true;
+
+        // Attempt to add the file to the file pool.
+        $fs = new file_system_filedir();
+        $result = $fs->add_file_from_string($filecontent);
+
+        // Test the output.
+        $this->assertEquals($contenthash, $result[0]);
+        $this->assertEquals(core_text::strlen($filecontent), $result[1]);
+        $this->assertTrue($result[2]);
+    }
+
+    /**
+     * Test that an appropriate error message is generated when adding a
+     * string to the pool when the pool directory structure is not writable.
+     */
+    public function test_add_file_from_string_existing_cannot_write_hashpath() {
+        $this->resetAfterTest();
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $filedircontent = [
+            '0f' => [],
+        ];
+        $vfileroot = $this->setup_vfile_root($filedircontent);
+
+        // Make the target path readonly.
+        $vfileroot->getChild('filedir/0f')
+            ->chmod(0444)
+            ->chown(\org\bovigo\vfs\vfsStream::OWNER_USER_2);
+
+        $this->setExpectedException(
+            'file_exception',
+            "Can not create local file pool directories, please verify permissions in dataroot."
+        );
+
+        // Attempt to add the file to the file pool.
+        $fs = new file_system_filedir();
+        $fs->add_file_from_string($filecontent);
+    }
+
+    /**
+     * Test adding a string to the pool when an item with the same
+     * contenthash is already present.
+     */
+    public function test_add_file_from_string_existing_matches() {
+        $this->resetAfterTest();
+        global $CFG;
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $filedircontent = [
+            '0f' => [
+                'f3' => [
+                    $contenthash => $filecontent,
+                ],
+            ],
+        ];
+
+        $vfileroot = $this->setup_vfile_root($filedircontent);
+
+        // Note, the vfs file system does not support locks - prevent file locking here.
+        $CFG->preventfilelocking = true;
+
+        // Attempt to add the file to the file pool.
+        $fs = new file_system_filedir();
+        $result = $fs->add_file_from_string($filecontent);
+
+        // Test the output.
+        $this->assertEquals($contenthash, $result[0]);
+        $this->assertEquals(core_text::strlen($filecontent), $result[1]);
+        $this->assertFalse($result[2]);
+    }
+
+    /**
+     * Test the cleanup of deleted files when there are no files to delete.
+     */
+    public function test_remove_file_missing() {
+        $this->resetAfterTest();
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $vfileroot = $this->setup_vfile_root();
+
+        $fs = new file_system_filedir();
+        $fs->remove_file($contenthash);
+
+        $this->assertFalse($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
+        // No file to move to trash, so the trash path will also be empty.
+        $this->assertFalse($vfileroot->hasChild('trashdir/0f'));
+        $this->assertFalse($vfileroot->hasChild('trashdir/0f/f3'));
+        $this->assertFalse($vfileroot->hasChild('trashdir/0f/f3/' . $contenthash));
+    }
+
+    /**
+     * Test the cleanup of deleted files when a file already exists in the
+     * trash for that path.
+     */
+    public function test_remove_file_existing_trash() {
+        $this->resetAfterTest();
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $filedircontent = $trashdircontent = [
+            '0f' => [
+                'f3' => [
+                    $contenthash => $filecontent,
+                ],
+            ],
+        ];
+        $trashdircontent['0f']['f3'][$contenthash] .= 'different';
+        $vfileroot = $this->setup_vfile_root($filedircontent, $trashdircontent);
+
+        $fs = new file_system_filedir();
+        $fs->remove_file($contenthash);
+
+        $this->assertFalse($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
+        $this->assertTrue($vfileroot->hasChild('trashdir/0f/f3/' . $contenthash));
+        $this->assertNotEquals($filecontent, $vfileroot->getChild('trashdir/0f/f3/' . $contenthash)->getContent());
+    }
+
+    /**
+     * Ensure that remove_file does nothing with an empty file.
+     */
+    public function test_remove_file_empty() {
+        $this->resetAfterTest();
+        global $DB;
+
+        $DB = $this->getMockBuilder(\moodle_database::class)
+            ->setMethods(['record_exists'])
+            ->getMockForAbstractClass();
+
+        $DB->expects($this->never())
+            ->method('record_exists');
+
+        $fs = new file_system_filedir();
+
+        $result = $fs->remove_file(sha1(''));
+        $this->assertNull($result);
+    }
+
+    /**
+     * Ensure that remove_file does nothing when a file is still
+     * in use.
+     */
+    public function test_remove_file_in_use() {
+        $this->resetAfterTest();
+        global $DB;
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $filedircontent = [
+            '0f' => [
+                'f3' => [
+                    $contenthash => $filecontent,
+                ],
+            ],
+        ];
+        $vfileroot = $this->setup_vfile_root($filedircontent);
+
+        $DB = $this->getMockBuilder(\moodle_database::class)
+            ->setMethods(['record_exists'])
+            ->getMockForAbstractClass();
+
+        $DB->method('record_exists')->willReturn(true);
+
+        $fs = new file_system_filedir();
+        $result = $fs->remove_file($contenthash);
+        $this->assertTrue($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
+        $this->assertFalse($vfileroot->hasChild('trashdir/0f/f3/' . $contenthash));
+    }
+
+    /**
+     * Ensure that remove_file removes the file when it is no
+     * longer in use.
+     */
+    public function test_remove_file_expired() {
+        $this->resetAfterTest();
+        global $DB;
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $filedircontent = [
+            '0f' => [
+                'f3' => [
+                    $contenthash => $filecontent,
+                ],
+            ],
+        ];
+        $vfileroot = $this->setup_vfile_root($filedircontent);
+
+        $DB = $this->getMockBuilder(\moodle_database::class)
+            ->setMethods(['record_exists'])
+            ->getMockForAbstractClass();
+
+        $DB->method('record_exists')->willReturn(false);
+
+        $fs = new file_system_filedir();
+        $result = $fs->remove_file($contenthash);
+        $this->assertFalse($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
+        $this->assertTrue($vfileroot->hasChild('trashdir/0f/f3/' . $contenthash));
+    }
+
+    /**
+     * Test purging the cache.
+     */
+    public function test_empty_trash() {
+        $this->resetAfterTest();
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $filedircontent = $trashdircontent = [
+            '0f' => [
+                'f3' => [
+                    $contenthash => $filecontent,
+                ],
+            ],
+        ];
+        $vfileroot = $this->setup_vfile_root($filedircontent, $trashdircontent);
+
+        $fs = new file_system_filedir();
+        $method = new ReflectionMethod(file_system_filedir::class, 'empty_trash');
+        $method->setAccessible(true);
+        $result = $method->invoke($fs);
+
+        $this->assertTrue($vfileroot->hasChild('filedir/0f/f3/' . $contenthash));
+        $this->assertFalse($vfileroot->hasChild('trashdir'));
+        $this->assertFalse($vfileroot->hasChild('trashdir/0f'));
+        $this->assertFalse($vfileroot->hasChild('trashdir/0f/f3'));
+        $this->assertFalse($vfileroot->hasChild('trashdir/0f/f3/' . $contenthash));
+    }
+
+    /**
+     * Data Provider for contenthash to contendir conversion.
+     *
+     * @return  array
+     */
+    public function contenthash_dataprovider() {
+        return array(
+            array(
+                'contenthash'   => 'eee4943847a35a4b6942c6f96daafde06bcfdfab',
+                'contentdir'    => 'ee/e4',
+            ),
+            array(
+                'contenthash'   => 'aef05a62ae81ca0005d2569447779af062b7cda0',
+                'contentdir'    => 'ae/f0',
+            ),
+        );
+    }
+}
diff --git a/lib/filestorage/tests/file_system_test.php b/lib/filestorage/tests/file_system_test.php
new file mode 100644
index 00000000000..2aeff76edab
--- /dev/null
+++ b/lib/filestorage/tests/file_system_test.php
@@ -0,0 +1,1091 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for file_system.
+ *
+ * @package   core_files
+ * @category  phpunit
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/filestorage/file_system.php');
+
+/**
+ * Unit tests for file_system.
+ *
+ * @package   core_files
+ * @category  phpunit
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_files_file_system_testcase extends advanced_testcase {
+
+    public function setUp() {
+        get_file_storage(true);
+    }
+
+    public function tearDown() {
+        get_file_storage(true);
+    }
+
+    /**
+     * Helper function to help setup and configure the virtual file system stream.
+     *
+     * @param   array $filedir Directory structure and content of the filedir
+     * @param   array $trashdir Directory structure and content of the sourcedir
+     * @param   array $sourcedir Directory structure and content of a directory used for source files for tests
+     * @return  \org\bovigo\vfs\vfsStream
+     */
+    protected function setup_vfile_root($content = []) {
+        $vfileroot = \org\bovigo\vfs\vfsStream::setup('root', null, $content);
+
+        return $vfileroot;
+    }
+
+    /**
+     * Helper to create a stored file objectw with the given supplied content.
+     *
+     * @param   string  $filecontent The content of the mocked file
+     * @param   string  $filename The file name to use in the stored_file
+     * @param   array   $mockedmethods A list of methods you intend to override
+     *                  If no methods are specified, only abstract functions are mocked.
+     * @return stored_file
+     */
+    protected function get_stored_file($filecontent, $filename = null, $mockedmethods = null) {
+        $contenthash = sha1($filecontent);
+        if (empty($filename)) {
+            $filename = $contenthash;
+        }
+
+        $file = $this->getMockBuilder(stored_file::class)
+            ->setMethods($mockedmethods)
+            ->setConstructorArgs([
+                get_file_storage(),
+                (object) [
+                    'contenthash' => $contenthash,
+                    'filesize' => strlen($filecontent),
+                    'filename' => $filename,
+                ]
+            ])
+            ->getMock();
+
+        return $file;
+    }
+
+    /**
+     * Get a testable mock of the abstract file_system class.
+     *
+     * @param   array   $mockedmethods A list of methods you intend to override
+     *                  If no methods are specified, only abstract functions are mocked.
+     * @return file_system
+     */
+    protected function get_testable_mock($mockedmethods = []) {
+        $fs = $this->getMockBuilder(file_system::class)
+            ->setMethods($mockedmethods)
+            ->getMockForAbstractClass();
+
+        return $fs;
+    }
+
+    /**
+     * Ensure that the file system is not clonable.
+     */
+    public function test_not_cloneable() {
+        $reflection = new ReflectionClass('file_system');
+        $this->assertFalse($reflection->isCloneable());
+    }
+
+    /**
+     * Ensure that the filedir file_system extension is used by default.
+     */
+    public function test_default_class() {
+        $this->resetAfterTest();
+
+        // Ensure that the alternative_file_system_class is null.
+        global $CFG;
+        $CFG->alternative_file_system_class = null;
+
+        $storage = get_file_storage();
+        $fs = $storage->get_file_system();
+        $this->assertInstanceOf(file_system::class, $fs);
+        $this->assertEquals(file_system_filedir::class, get_class($fs));
+    }
+
+    /**
+     * Ensure that the specified file_system extension class is used.
+     */
+    public function test_supplied_class() {
+        global $CFG;
+        $this->resetAfterTest();
+
+        // Mock the file_system.
+        // Mocks create a new child of the mocked class which is perfect for this test.
+        $filesystem = $this->getMockBuilder('file_system')
+            ->disableOriginalConstructor()
+            ->getMock();
+        $CFG->alternative_file_system_class = get_class($filesystem);
+
+        $storage = get_file_storage();
+        $fs = $storage->get_file_system();
+        $this->assertInstanceOf(file_system::class, $fs);
+        $this->assertEquals(get_class($filesystem), get_class($fs));
+    }
+
+    /**
+     * Test that the readfile function outputs content to disk.
+     */
+    public function test_readfile_remote() {
+        global $CFG;
+
+        // Mock the filesystem.
+        $filecontent = 'example content';
+        $vfileroot = $this->setup_vfile_root(['sourcefile' => $filecontent]);
+        $filepath = \org\bovigo\vfs\vfsStream::url('root/sourcefile');
+
+        $file = $this->get_stored_file($filecontent);
+
+        // Mock the file_system class.
+        // We need to override the get_remote_path_from_storedfile function.
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_storedfile',
+            'is_file_readable_locally_by_storedfile',
+            'get_local_path_from_storedfile',
+        ]);
+        $fs->method('get_remote_path_from_storedfile')->willReturn($filepath);
+        $fs->method('is_file_readable_locally_by_storedfile')->willReturn(false);
+        $fs->expects($this->never())->method('get_local_path_from_storedfile');
+
+        // Note: It is currently not possible to mock readfile_allow_large
+        // because file_system is in the global namespace.
+        // We must therefore check for expected output. This is not ideal.
+        $this->expectOutputString($filecontent);
+        $fs->readfile($file);
+    }
+
+    /**
+     * Test that the readfile function outputs content to disk.
+     */
+    public function test_readfile_local() {
+        global $CFG;
+
+        // Mock the filesystem.
+        $filecontent = 'example content';
+        $vfileroot = $this->setup_vfile_root(['sourcefile' => $filecontent]);
+        $filepath = \org\bovigo\vfs\vfsStream::url('root/sourcefile');
+
+        $file = $this->get_stored_file($filecontent);
+
+        // Mock the file_system class.
+        // We need to override the get_remote_path_from_storedfile function.
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_storedfile',
+            'is_file_readable_locally_by_storedfile',
+            'get_local_path_from_storedfile',
+        ]);
+        $fs->method('is_file_readable_locally_by_storedfile')->willReturn(true);
+        $fs->expects($this->never())->method('get_remote_path_from_storedfile');
+        $fs->expects($this->once())->method('get_local_path_from_storedfile')->willReturn($filepath);
+
+        // Note: It is currently not possible to mock readfile_allow_large
+        // because file_system is in the global namespace.
+        // We must therefore check for expected output. This is not ideal.
+        $this->expectOutputString($filecontent);
+        $fs->readfile($file);
+    }
+
+    /**
+     * Test that the get_local_path_from_storedfile function functions
+     * correctly when called with various args.
+     *
+     * @dataProvider get_local_path_from_storedfile_provider
+     * @param   array   $args The additional args to pass to get_local_path_from_storedfile
+     * @param   bool    $fetch Whether the combination of args should have caused a fetch
+     */
+    public function test_get_local_path_from_storedfile($args, $fetch) {
+        $filepath = '/path/to/file';
+        $filecontent = 'example content';
+
+        // Get the filesystem mock.
+        $fs = $this->get_testable_mock([
+            'get_local_path_from_hash',
+        ]);
+        $fs->expects($this->once())
+            ->method('get_local_path_from_hash')
+            ->with($this->equalTo(sha1($filecontent)), $this->equalTo($fetch))
+            ->willReturn($filepath);
+
+        $file = $this->get_stored_file($filecontent);
+
+        $method = new ReflectionMethod(file_system::class, 'get_local_path_from_storedfile');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, array_merge([$file], $args));
+
+        $this->assertEquals($filepath, $result);
+    }
+
+    /**
+     * Ensure that the default implementation of get_remote_path_from_storedfile
+     * simply calls get_local_path_from_storedfile without requiring a
+     * fetch.
+     */
+    public function test_get_remote_path_from_storedfile() {
+        $filepath = '/path/to/file';
+        $filecontent = 'example content';
+
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_hash',
+        ]);
+
+        $fs->expects($this->once())
+            ->method('get_remote_path_from_hash')
+            ->with($this->equalTo(sha1($filecontent)), $this->equalTo(false))
+            ->willReturn($filepath);
+
+        $file = $this->get_stored_file($filecontent);
+
+        $method = new ReflectionMethod(file_system::class, 'get_remote_path_from_storedfile');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, [$file]);
+
+        $this->assertEquals($filepath, $result);
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_locally_by_hash with a valid file.
+     *
+     * This should call get_local_path_from_hash and check the readability
+     * of the file.
+     *
+     * Fetching the file is optional.
+     */
+    public function test_is_file_readable_locally_by_hash() {
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $filepath = __FILE__;
+
+        $fs = $this->get_testable_mock([
+            'get_local_path_from_hash',
+        ]);
+
+        $fs->method('get_local_path_from_hash')
+            ->with($this->equalTo($contenthash), $this->equalTo(false))
+            ->willReturn($filepath);
+
+        $this->assertTrue($fs->is_file_readable_locally_by_hash($contenthash));
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_locally_by_hash with an empty file.
+     */
+    public function test_is_file_readable_locally_by_hash_empty() {
+        $filecontent = '';
+        $contenthash = sha1($filecontent);
+
+        $fs = $this->get_testable_mock([
+            'get_local_path_from_hash',
+        ]);
+
+        $fs->expects($this->never())
+            ->method('get_local_path_from_hash');
+
+        $this->assertTrue($fs->is_file_readable_locally_by_hash($contenthash));
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_remotely_by_storedfile with a valid file.
+     */
+    public function test_is_file_readable_remotely_by_hash() {
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_hash',
+        ]);
+
+        $fs->method('get_remote_path_from_hash')
+            ->with($this->equalTo($contenthash), $this->equalTo(false))
+            ->willReturn(__FILE__);
+
+        $this->assertTrue($fs->is_file_readable_remotely_by_hash($contenthash));
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_remotely_by_storedfile with a valid file.
+     */
+    public function test_is_file_readable_remotely_by_hash_empty() {
+        $filecontent = '';
+        $contenthash = sha1($filecontent);
+
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_hash',
+        ]);
+
+        $fs->expects($this->never())
+            ->method('get_remote_path_from_hash');
+
+        $this->assertTrue($fs->is_file_readable_remotely_by_hash($contenthash));
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_remotely_by_storedfile with a valid file.
+     */
+    public function test_is_file_readable_remotely_by_hash_not_found() {
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_hash',
+        ]);
+
+        $fs->method('get_remote_path_from_hash')
+            ->with($this->equalTo($contenthash), $this->equalTo(false))
+            ->willReturn('/path/to/nonexistent/file');
+
+        $this->assertFalse($fs->is_file_readable_remotely_by_hash($contenthash));
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_remotely_by_storedfile with a valid file.
+     */
+    public function test_is_file_readable_remotely_by_storedfile() {
+        $file = $this->get_stored_file('example content');
+
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_storedfile',
+        ]);
+
+        $fs->method('get_remote_path_from_storedfile')
+            ->willReturn(__FILE__);
+
+        $this->assertTrue($fs->is_file_readable_remotely_by_storedfile($file));
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_remotely_by_storedfile with a valid file.
+     */
+    public function test_is_file_readable_remotely_by_storedfile_empty() {
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_storedfile',
+        ]);
+
+        $fs->expects($this->never())
+            ->method('get_remote_path_from_storedfile');
+
+        $file = $this->get_stored_file('');
+        $this->assertTrue($fs->is_file_readable_remotely_by_storedfile($file));
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_locally_by_storedfile with an empty file.
+     */
+    public function test_is_file_readable_locally_by_storedfile_empty() {
+        $fs = $this->get_testable_mock([
+            'get_local_path_from_storedfile',
+        ]);
+
+        $fs->expects($this->never())
+            ->method('get_local_path_from_storedfile');
+
+        $file = $this->get_stored_file('');
+        $this->assertTrue($fs->is_file_readable_locally_by_storedfile($file));
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_remotely_by_storedfile with a valid file.
+     */
+    public function test_is_file_readable_remotely_by_storedfile_not_found() {
+        $file = $this->get_stored_file('example content');
+
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_storedfile',
+        ]);
+
+        $fs->method('get_remote_path_from_storedfile')
+            ->willReturn(__LINE__);
+
+        $this->assertFalse($fs->is_file_readable_remotely_by_storedfile($file));
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_locally_by_storedfile with a valid file.
+     */
+    public function test_is_file_readable_locally_by_storedfile_unreadable() {
+        $fs = $this->get_testable_mock([
+            'get_local_path_from_storedfile',
+        ]);
+        $file = $this->get_stored_file('example content');
+
+        $fs->method('get_local_path_from_storedfile')
+            ->with($this->equalTo($file), $this->equalTo(false))
+            ->willReturn('/path/to/nonexistent/file');
+
+        $this->assertFalse($fs->is_file_readable_locally_by_storedfile($file));
+    }
+
+    /**
+     * Test the stock implementation of is_file_readable_locally_by_storedfile with a valid file should pass fetch.
+     */
+    public function test_is_file_readable_locally_by_storedfile_passes_fetch() {
+        $fs = $this->get_testable_mock([
+            'get_local_path_from_storedfile',
+        ]);
+        $file = $this->get_stored_file('example content');
+
+        $fs->method('get_local_path_from_storedfile')
+            ->with($this->equalTo($file), $this->equalTo(true))
+            ->willReturn('/path/to/nonexistent/file');
+
+        $this->assertFalse($fs->is_file_readable_locally_by_storedfile($file, true));
+    }
+
+    /**
+     * Ensure that is_file_removable returns correctly for an empty file.
+     */
+    public function test_is_file_removable_empty() {
+        $filecontent = '';
+        $contenthash = sha1($filecontent);
+
+        $method = new ReflectionMethod(file_system::class, 'is_file_removable');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs(null, [$contenthash]);
+        $this->assertFalse($result);
+    }
+
+    /**
+     * Ensure that is_file_removable returns false if the file is still in use.
+     */
+    public function test_is_file_removable_in_use() {
+        $this->resetAfterTest();
+        global $DB;
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $DB = $this->getMockBuilder(\moodle_database::class)
+            ->setMethods(['record_exists'])
+            ->getMockForAbstractClass();
+        $DB->method('record_exists')->willReturn(true);
+
+        $method = new ReflectionMethod(file_system::class, 'is_file_removable');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs(null, [$contenthash]);
+
+        $this->assertFalse($result);
+    }
+
+    /**
+     * Ensure that is_file_removable returns false if the file is not in use.
+     */
+    public function test_is_file_removable_not_in_use() {
+        $this->resetAfterTest();
+        global $DB;
+
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+
+        $DB = $this->getMockBuilder(\moodle_database::class)
+            ->setMethods(['record_exists'])
+            ->getMockForAbstractClass();
+        $DB->method('record_exists')->willReturn(false);
+
+        $method = new ReflectionMethod(file_system::class, 'is_file_removable');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs(null, [$contenthash]);
+
+        $this->assertTrue($result);
+    }
+
+    /**
+     * Test the stock implementation of get_content.
+     */
+    public function test_get_content() {
+        global $CFG;
+
+        // Mock the filesystem.
+        $filecontent = 'example content';
+        $vfileroot = $this->setup_vfile_root(['sourcefile' => $filecontent]);
+        $filepath = \org\bovigo\vfs\vfsStream::url('root/sourcefile');
+
+        $file = $this->get_stored_file($filecontent);
+
+        // Mock the file_system class.
+        // We need to override the get_remote_path_from_storedfile function.
+        $fs = $this->get_testable_mock(['get_remote_path_from_storedfile']);
+        $fs->method('get_remote_path_from_storedfile')->willReturn($filepath);
+
+        $result = $fs->get_content($file);
+
+        $this->assertEquals($filecontent, $result);
+    }
+
+    /**
+     * Test the stock implementation of get_content.
+     */
+    public function test_get_content_empty() {
+        global $CFG;
+
+        $filecontent = '';
+        $file = $this->get_stored_file($filecontent);
+
+        // Mock the file_system class.
+        // We need to override the get_remote_path_from_storedfile function.
+        $fs = $this->get_testable_mock(['get_remote_path_from_storedfile']);
+        $fs->expects($this->never())
+            ->method('get_remote_path_from_storedfile');
+
+        $result = $fs->get_content($file);
+
+        $this->assertEquals($filecontent, $result);
+    }
+
+    /**
+     * Ensure that the list_files function requires a local copy of the
+     * file, and passes the path to the packer.
+     */
+    public function test_list_files() {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent);
+        $filepath = __FILE__;
+        $expectedresult = (object) [];
+
+        // Mock the file_system class.
+        $fs = $this->get_testable_mock(['get_local_path_from_storedfile']);
+        $fs->method('get_local_path_from_storedfile')
+            ->with($this->equalTo($file), $this->equalTo(true))
+            ->willReturn(__FILE__);
+
+        $packer = $this->getMockBuilder(file_packer::class)
+            ->setMethods(['list_files'])
+            ->getMockForAbstractClass();
+
+        $packer->expects($this->once())
+            ->method('list_files')
+            ->with($this->equalTo($filepath))
+            ->willReturn($expectedresult);
+
+        $result = $fs->list_files($file, $packer);
+
+        $this->assertEquals($expectedresult, $result);
+    }
+
+    /**
+     * Ensure that the extract_to_pathname function requires a local copy of the
+     * file, and passes the path to the packer.
+     */
+    public function test_extract_to_pathname() {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent);
+        $filepath = __FILE__;
+        $expectedresult = (object) [];
+        $outputpath = '/path/to/output';
+
+        // Mock the file_system class.
+        $fs = $this->get_testable_mock(['get_local_path_from_storedfile']);
+        $fs->method('get_local_path_from_storedfile')
+            ->with($this->equalTo($file), $this->equalTo(true))
+            ->willReturn(__FILE__);
+
+        $packer = $this->getMockBuilder(file_packer::class)
+            ->setMethods(['extract_to_pathname'])
+            ->getMockForAbstractClass();
+
+        $packer->expects($this->once())
+            ->method('extract_to_pathname')
+            ->with($this->equalTo($filepath), $this->equalTo($outputpath), $this->equalTo(null), $this->equalTo(null))
+            ->willReturn($expectedresult);
+
+        $result = $fs->extract_to_pathname($file, $packer, $outputpath);
+
+        $this->assertEquals($expectedresult, $result);
+    }
+
+    /**
+     * Ensure that the extract_to_storage function requires a local copy of the
+     * file, and passes the path to the packer.
+     */
+    public function test_extract_to_storage() {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent);
+        $filepath = __FILE__;
+        $expectedresult = (object) [];
+        $outputpath = '/path/to/output';
+
+        // Mock the file_system class.
+        $fs = $this->get_testable_mock(['get_local_path_from_storedfile']);
+        $fs->method('get_local_path_from_storedfile')
+            ->with($this->equalTo($file), $this->equalTo(true))
+            ->willReturn(__FILE__);
+
+        $packer = $this->getMockBuilder(file_packer::class)
+            ->setMethods(['extract_to_storage'])
+            ->getMockForAbstractClass();
+
+        $packer->expects($this->once())
+            ->method('extract_to_storage')
+            ->with(
+                $this->equalTo($filepath),
+                $this->equalTo(42),
+                $this->equalTo('component'),
+                $this->equalTo('filearea'),
+                $this->equalTo('itemid'),
+                $this->equalTo('pathbase'),
+                $this->equalTo('userid'),
+                $this->equalTo(null)
+            )
+            ->willReturn($expectedresult);
+
+        $result = $fs->extract_to_storage($file, $packer, 42, 'component','filearea', 'itemid', 'pathbase', 'userid');
+
+        $this->assertEquals($expectedresult, $result);
+    }
+
+    /**
+     * Ensure that the add_storedfile_to_archive function requires a local copy of the
+     * file, and passes the path to the archive.
+     */
+    public function test_add_storedfile_to_archive_directory() {
+        $file = $this->get_stored_file('', '.');
+        $archivepath = 'example';
+        $expectedresult = (object) [];
+
+        // Mock the file_system class.
+        $fs = $this->get_testable_mock(['get_local_path_from_storedfile']);
+        $fs->method('get_local_path_from_storedfile')
+            ->with($this->equalTo($file), $this->equalTo(true))
+            ->willReturn(__FILE__);
+
+        $archive = $this->getMockBuilder(file_archive::class)
+            ->setMethods([
+                'add_directory',
+                'add_file_from_pathname',
+            ])
+            ->getMockForAbstractClass();
+
+        $archive->expects($this->once())
+            ->method('add_directory')
+            ->with($this->equalTo($archivepath))
+            ->willReturn($expectedresult);
+
+        $archive->expects($this->never())
+            ->method('add_file_from_pathname');
+
+        $result = $fs->add_storedfile_to_archive($file, $archive, $archivepath);
+
+        $this->assertEquals($expectedresult, $result);
+    }
+
+    /**
+     * Ensure that the add_storedfile_to_archive function requires a local copy of the
+     * file, and passes the path to the archive.
+     */
+    public function test_add_storedfile_to_archive_file() {
+        $file = $this->get_stored_file('example content');
+        $filepath = __LINE__;
+        $archivepath = 'example';
+        $expectedresult = (object) [];
+
+        // Mock the file_system class.
+        $fs = $this->get_testable_mock(['get_local_path_from_storedfile']);
+        $fs->method('get_local_path_from_storedfile')
+            ->with($this->equalTo($file), $this->equalTo(true))
+            ->willReturn($filepath);
+
+        $archive = $this->getMockBuilder(file_archive::class)
+            ->setMethods([
+                'add_directory',
+                'add_file_from_pathname',
+            ])
+            ->getMockForAbstractClass();
+
+        $archive->expects($this->never())
+            ->method('add_directory');
+
+        $archive->expects($this->once())
+            ->method('add_file_from_pathname')
+            ->with(
+                $this->equalTo($archivepath),
+                $this->equalTo($filepath)
+            )
+            ->willReturn($expectedresult);
+
+        $result = $fs->add_storedfile_to_archive($file, $archive, $archivepath);
+
+        $this->assertEquals($expectedresult, $result);
+    }
+
+    /**
+     * Ensure that the add_to_curl_request function requires a local copy of the
+     * file, and passes the path to curl_file_create.
+     */
+    public function test_add_to_curl_request() {
+        $file = $this->get_stored_file('example content');
+        $filepath = __FILE__;
+        $archivepath = 'example';
+        $key = 'myfile';
+
+        // Mock the file_system class.
+        $fs = $this->get_testable_mock(['get_local_path_from_storedfile']);
+        $fs->method('get_local_path_from_storedfile')
+            ->with($this->equalTo($file), $this->equalTo(true))
+            ->willReturn($filepath);
+
+        $request = (object) ['_tmp_file_post_params' => []];
+        $fs->add_to_curl_request($file, $request, $key);
+        $this->assertArrayHasKey($key, $request->_tmp_file_post_params);
+        $this->assertEquals($filepath, $request->_tmp_file_post_params[$key]->name);
+    }
+
+    /**
+     * Ensure that test_get_imageinfo_not_image returns false if the file
+     * passed was deemed to not be an image.
+     */
+    public function test_get_imageinfo_not_image() {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent);
+
+        $fs = $this->get_testable_mock([
+            'is_image_from_storedfile',
+        ]);
+
+        $fs->expects($this->once())
+            ->method('is_image_from_storedfile')
+            ->with($this->equalTo($file))
+            ->willReturn(false);
+
+        $this->assertFalse($fs->get_imageinfo($file));
+    }
+
+    /**
+     * Ensure that test_get_imageinfo_not_image returns imageinfo.
+     */
+    public function test_get_imageinfo() {
+        $filepath = '/path/to/file';
+        $filecontent = 'example content';
+        $expectedresult = (object) [];
+        $file = $this->get_stored_file($filecontent);
+
+        $fs = $this->get_testable_mock([
+            'is_image_from_storedfile',
+            'get_local_path_from_storedfile',
+            'get_imageinfo_from_path',
+        ]);
+
+        $fs->expects($this->once())
+            ->method('is_image_from_storedfile')
+            ->with($this->equalTo($file))
+            ->willReturn(true);
+
+        $fs->expects($this->once())
+            ->method('get_local_path_from_storedfile')
+            ->with($this->equalTo($file), $this->equalTo(true))
+            ->willReturn($filepath);
+
+        $fs->expects($this->once())
+            ->method('get_imageinfo_from_path')
+            ->with($this->equalTo($filepath))
+            ->willReturn($expectedresult);
+
+        $this->assertEquals($expectedresult, $fs->get_imageinfo($file));
+    }
+
+    /**
+     * Ensure that is_image_from_storedfile always returns false for an
+     * empty file size.
+     */
+    public function test_is_image_empty_filesize() {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent, null, ['get_filesize']);
+
+        $file->expects($this->once())
+            ->method('get_filesize')
+            ->willReturn(0);
+
+        $fs = $this->get_testable_mock();
+        $this->assertFalse($fs->is_image_from_storedfile($file));
+    }
+
+    /**
+     * Ensure that is_image_from_storedfile behaves correctly based on
+     * mimetype.
+     *
+     * @dataProvider is_image_from_storedfile_provider
+     * @param   string  $mimetype Mimetype to test
+     * @param   bool    $isimage Whether this mimetype should be detected as an image
+     */
+    public function test_is_image_from_storedfile_mimetype($mimetype, $isimage) {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent, null, ['get_mimetype']);
+
+        $file->expects($this->once())
+            ->method('get_mimetype')
+            ->willReturn($mimetype);
+
+        $fs = $this->get_testable_mock();
+        $this->assertEquals($isimage, $fs->is_image_from_storedfile($file));
+    }
+
+    /**
+     * Test that get_imageinfo_from_path returns an appropriate response
+     * for an image.
+     */
+    public function test_get_imageinfo_from_path() {
+        $filepath = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'testimage.jpg';
+
+        // Get the filesystem mock.
+        $fs = $this->get_testable_mock();
+
+        $method = new ReflectionMethod(file_system::class, 'get_imageinfo_from_path');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, [$filepath]);
+
+        $this->assertArrayHasKey('width', $result);
+        $this->assertArrayHasKey('height', $result);
+        $this->assertArrayHasKey('mimetype', $result);
+        $this->assertEquals('image/jpeg', $result['mimetype']);
+    }
+
+    /**
+     * Test that get_imageinfo_from_path returns an appropriate response
+     * for a file which is not an image.
+     */
+    public function test_get_imageinfo_from_path_no_image() {
+        $filepath = __FILE__;
+
+        // Get the filesystem mock.
+        $fs = $this->get_testable_mock();
+
+        $method = new ReflectionMethod(file_system::class, 'get_imageinfo_from_path');
+        $method->setAccessible(true);
+        $result = $method->invokeArgs($fs, [$filepath]);
+
+        $this->assertFalse($result);
+    }
+
+    /**
+     * Ensure that get_content_file_handle returns a valid file handle.
+     */
+    public function test_get_content_file_handle_default() {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent);
+
+        $fs = $this->get_testable_mock(['get_remote_path_from_storedfile']);
+        $fs->method('get_remote_path_from_storedfile')
+            ->willReturn(__FILE__);
+
+        // Note: We are unable to determine the mode in which the $fh was opened.
+        $fh = $fs->get_content_file_handle($file);
+        $this->assertTrue(is_resource($fh));
+        $this->assertEquals('stream', get_resource_type($fh));
+        fclose($fh);
+    }
+
+    /**
+     * Ensure that get_content_file_handle returns a valid file handle for a gz file.
+     */
+    public function test_get_content_file_handle_gz() {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent);
+
+        $fs = $this->get_testable_mock(['get_remote_path_from_storedfile']);
+        $fs->method('get_remote_path_from_storedfile')
+            ->willReturn(__DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'test.tgz');
+
+        // Note: We are unable to determine the mode in which the $fh was opened.
+        $fh = $fs->get_content_file_handle($file, stored_file::FILE_HANDLE_GZOPEN);
+        $this->assertTrue(is_resource($fh));
+        gzclose($fh);
+    }
+
+    /**
+     * Ensure that get_content_file_handle returns an exception when calling for a invalid file handle type.
+     */
+    public function test_get_content_file_handle_invalid() {
+        $filecontent = 'example content';
+        $file = $this->get_stored_file($filecontent);
+
+        $fs = $this->get_testable_mock(['get_remote_path_from_storedfile']);
+        $fs->method('get_remote_path_from_storedfile')
+            ->willReturn(__FILE__);
+
+        $this->expectException('coding_exception', 'Unexpected file handle type');
+        $fs->get_content_file_handle($file, -1);
+    }
+
+    /**
+     * Test that mimetype_from_hash returns the correct mimetype with
+     * a file whose filename suggests mimetype.
+     */
+    public function test_mimetype_from_hash_using_filename() {
+        $filepath = '/path/to/file/not/currently/on/disk';
+        $filecontent = 'example content';
+        $filename = 'test.jpg';
+        $contenthash = sha1($filecontent);
+
+        $fs = $this->get_testable_mock(['get_remote_path_from_hash']);
+        $fs->method('get_remote_path_from_hash')->willReturn($filepath);
+
+        $result = $fs->mimetype_from_hash($contenthash, $filename);
+        $this->assertEquals('image/jpeg', $result);
+    }
+
+    /**
+     * Test that mimetype_from_hash returns the correct mimetype with
+     * a locally available file whose filename does not suggest mimetype.
+     */
+    public function test_mimetype_from_hash_using_file_content() {
+        $filepath = '/path/to/file/not/currently/on/disk';
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $filename = 'example';
+
+        $filepath = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'testimage.jpg';
+        $fs = $this->get_testable_mock(['get_remote_path_from_hash']);
+        $fs->method('get_remote_path_from_hash')->willReturn($filepath);
+
+        $result = $fs->mimetype_from_hash($contenthash, $filename);
+        $this->assertEquals('image/jpeg', $result);
+    }
+
+    /**
+     * Test that mimetype_from_hash returns the correct mimetype with
+     * a remotely available file whose filename does not suggest mimetype.
+     */
+    public function test_mimetype_from_hash_using_file_content_remote() {
+        $filepath = '/path/to/file/not/currently/on/disk';
+        $filecontent = 'example content';
+        $contenthash = sha1($filecontent);
+        $filename = 'example';
+
+        $filepath = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'testimage.jpg';
+
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_hash',
+            'is_file_readable_locally_by_hash',
+            'get_local_path_from_hash',
+        ]);
+
+        $fs->method('get_remote_path_from_hash')->willReturn('/path/to/remote/file');
+        $fs->method('is_file_readable_locally_by_hash')->willReturn(false);
+        $fs->method('get_local_path_from_hash')->willReturn($filepath);
+
+        $result = $fs->mimetype_from_hash($contenthash, $filename);
+        $this->assertEquals('image/jpeg', $result);
+    }
+
+    /**
+     * Test that mimetype_from_storedfile returns the correct mimetype with
+     * a file whose filename suggests mimetype.
+     */
+    public function test_mimetype_from_storedfile_empty() {
+        $file = $this->get_stored_file('');
+
+        $fs = $this->get_testable_mock();
+        $result = $fs->mimetype_from_storedfile($file);
+        $this->assertNull($result);
+    }
+
+    /**
+     * Test that mimetype_from_storedfile returns the correct mimetype with
+     * a file whose filename suggests mimetype.
+     */
+    public function test_mimetype_from_storedfile_using_filename() {
+        $filepath = '/path/to/file/not/currently/on/disk';
+        $fs = $this->get_testable_mock(['get_remote_path_from_storedfile']);
+        $fs->method('get_remote_path_from_storedfile')->willReturn($filepath);
+
+        $file = $this->get_stored_file('example content', 'test.jpg');
+
+        $result = $fs->mimetype_from_storedfile($file);
+        $this->assertEquals('image/jpeg', $result);
+    }
+
+    /**
+     * Test that mimetype_from_storedfile returns the correct mimetype with
+     * a locally available file whose filename does not suggest mimetype.
+     */
+    public function test_mimetype_from_storedfile_using_file_content() {
+        $filepath = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'testimage.jpg';
+        $fs = $this->get_testable_mock(['get_remote_path_from_storedfile']);
+        $fs->method('get_remote_path_from_storedfile')->willReturn($filepath);
+
+        $file = $this->get_stored_file('example content');
+
+        $result = $fs->mimetype_from_storedfile($file);
+        $this->assertEquals('image/jpeg', $result);
+    }
+
+    /**
+     * Test that mimetype_from_storedfile returns the correct mimetype with
+     * a remotely available file whose filename does not suggest mimetype.
+     */
+    public function test_mimetype_from_storedfile_using_file_content_remote() {
+        $filepath = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'testimage.jpg';
+
+        $fs = $this->get_testable_mock([
+            'get_remote_path_from_storedfile',
+            'is_file_readable_locally_by_storedfile',
+            'get_local_path_from_storedfile',
+        ]);
+
+        $fs->method('get_remote_path_from_storedfile')->willReturn('/path/to/remote/file');
+        $fs->method('is_file_readable_locally_by_storedfile')->willReturn(false);
+        $fs->method('get_local_path_from_storedfile')->willReturn($filepath);
+
+        $file = $this->get_stored_file('example content');
+
+        $result = $fs->mimetype_from_storedfile($file);
+        $this->assertEquals('image/jpeg', $result);
+    }
+
+    /**
+     * Data Provider for is_image_from_storedfile tests.
+     *
+     * @return array
+     */
+    public function is_image_from_storedfile_provider() {
+        return array(
+            'Standard image'            => array('image/png', true),
+            'Made up document/image'    => array('document/image', false),
+        );
+    }
+
+    /**
+     * Data provider for get_local_path_from_storedfile tests.
+     *
+     * @return array
+     */
+    public function get_local_path_from_storedfile_provider() {
+        return [
+            'default args (nofetch)' => [
+                'args' => [],
+                'fetch' => 0,
+            ],
+            'explicit: nofetch' => [
+                'args' => [false],
+                'fetch' => 0,
+            ],
+            'explicit: fetch' => [
+                'args' => [true],
+                'fetch' => 1,
+            ],
+        ];
+    }
+}
diff --git a/lib/filestorage/tests/fixtures/test.tgz b/lib/filestorage/tests/fixtures/test.tgz
new file mode 100644
index 0000000000000000000000000000000000000000..6c92dce1e603447c10bc2e3c638c6478dbdee568
GIT binary patch
diff --git a/lib/moodlelib.php b/lib/moodlelib.php
index 71a93d70084..76276de4a76 100644
--- a/lib/moodlelib.php
+++ b/lib/moodlelib.php
@@ -6243,30 +6243,23 @@ function email_is_not_allowed($email) {
  *
  * @return file_storage
  */
-function get_file_storage() {
+function get_file_storage($reset = false) {
     global $CFG;
 
     static $fs = null;
 
+    if ($reset) {
+        $fs = null;
+        return;
+    }
+
     if ($fs) {
         return $fs;
     }
 
     require_once("$CFG->libdir/filelib.php");
 
-    if (isset($CFG->filedir)) {
-        $filedir = $CFG->filedir;
-    } else {
-        $filedir = $CFG->dataroot.'/filedir';
-    }
-
-    if (isset($CFG->trashdir)) {
-        $trashdirdir = $CFG->trashdir;
-    } else {
-        $trashdirdir = $CFG->dataroot.'/trashdir';
-    }
-
-    $fs = new file_storage($filedir, $trashdirdir, "$CFG->tempdir/filestorage", $CFG->directorypermissions, $CFG->filepermissions);
+    $fs = new file_storage();
 
     return $fs;
 }
diff --git a/question/format/blackboard_six/formatbase.php b/question/format/blackboard_six/formatbase.php
index 5033827650a..a2bf4fbff8b 100644
--- a/question/format/blackboard_six/formatbase.php
+++ b/question/format/blackboard_six/formatbase.php
@@ -47,7 +47,7 @@ class qformat_blackboard_six_base extends qformat_based_on_xml {
 
     /**
      * Check if the given file is capable of being imported by this plugin.
-     * As {@link file_storage::mimetype()} now uses finfo PHP extension if available,
+     * As {@link file_storage::mimetype()} may use finfo PHP extension if available,
      * the value returned by $file->get_mimetype for a .dat file is not the same on all servers.
      * So we must made 2 checks to verify if the plugin can import the file.
      * @param stored_file $file the file to check
diff --git a/repository/lib.php b/repository/lib.php
index 188193dcf46..723bd027c82 100644
--- a/repository/lib.php
+++ b/repository/lib.php
@@ -1717,12 +1717,13 @@ abstract class repository implements cacheable_object {
                                                                         'size' => $maxbytesdisplay));
             }
             $fs = get_file_storage();
-            $contentexists = $fs->content_exists($file->get_contenthash());
-            if ($contentexists && $file->get_filesize() && $file->get_contenthash() === sha1('')) {
-                // even when 'file_storage::content_exists()' returns true this may be an empty
-                // content for the file that was not actually downloaded
-                $contentexists = false;
-            }
+
+            // If a file has been downloaded, the file record should report both a positive file
+            // size, and a contenthash which does not related to empty content.
+            // If thereis no file size, or the contenthash is for an empty file, then the file has
+            // yet to be successfully downloaded.
+            $contentexists = $file->get_filesize() && $file->get_contenthash() !== sha1('');
+
             if (!$file->get_status() && $contentexists) {
                 // we already have the content in moodle filepool and it was synchronised recently.
                 // Repositories may overwrite it if they want to force synchronisation anyway!
-- 
2.17.1


From a9df728542e9c7dab8e0f7cf7d153d926e4f0dea Mon Sep 17 00:00:00 2001
From: Andrew Nicols <andrew@nicols.co.uk>
Date: Wed, 8 Feb 2017 13:14:51 +0800
Subject: [PATCH 2/7] MDL-46375 core_files: Correct filename in mbz test

I noticed during the file system abstraction that this test was
incorrect.

Since both $storagefalse, and $storagetrue are in the same context,
component, area, itemid, and folder, the fact that they had the same
filename meant that they constantly overwrote one another.

As part of archive_to_storage, existing files in the same location are
found, the files themselves deleted, and the existing file record in the
files table is deleted.

The tests continued to pass because:
* the existing variables were not affected by the deletion of the file
  record and file so the comparisons were successful; and
* subsequent calls to fetch the content of the file meant that the
  files themselves were restored from the trash directory.
---
 lib/filestorage/tests/mbz_packer_test.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/filestorage/tests/mbz_packer_test.php b/lib/filestorage/tests/mbz_packer_test.php
index 5882218379a..34d85d5c27d 100644
--- a/lib/filestorage/tests/mbz_packer_test.php
+++ b/lib/filestorage/tests/mbz_packer_test.php
@@ -54,7 +54,7 @@ class core_files_mbz_packer_testcase extends advanced_testcase {
         $this->assertNotEmpty($packer->archive_to_pathname($files, $filetrue));
         $context = context_system::instance();
         $this->assertNotEmpty($storagetrue = $packer->archive_to_storage(
-                $files, $context->id, 'phpunit', 'data', 0, '/', 'false.mbz'));
+                $files, $context->id, 'phpunit', 'data', 0, '/', 'true.mbz'));
 
         // Check the sizes are different (indicating different formats).
         $this->assertNotEquals(filesize($filefalse), filesize($filetrue));
-- 
2.17.1


From 9f93ff48ad8c1f9202b298b4b01c0bfdb8c34579 Mon Sep 17 00:00:00 2001
From: Rajesh Taneja <rajesh@moodle.com>
Date: Tue, 28 Feb 2017 14:58:49 +0800
Subject: [PATCH 3/7] MDL-58068 testing: Whitelist
 alternative_file_system_class in testing

Unit test and behat should be allowed to
run with alternative file system
---
 lib/behat/lib.php         | 2 +-
 lib/phpunit/bootstrap.php | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/lib/behat/lib.php b/lib/behat/lib.php
index 662a784ec18..07348b54fb9 100644
--- a/lib/behat/lib.php
+++ b/lib/behat/lib.php
@@ -220,7 +220,7 @@ function behat_clean_init_config() {
         'umaskpermissions', 'dbtype', 'dblibrary', 'dbhost', 'dbname', 'dbuser', 'dbpass', 'prefix',
         'dboptions', 'proxyhost', 'proxyport', 'proxytype', 'proxyuser', 'proxypassword',
         'proxybypass', 'theme', 'pathtogs', 'pathtodu', 'aspellpath', 'pathtodot', 'skiplangupgrade',
-        'altcacheconfigpath', 'pathtounoconv'
+        'altcacheconfigpath', 'pathtounoconv', 'alternative_file_system_class'
     ));
 
     // Add extra allowed settings.
diff --git a/lib/phpunit/bootstrap.php b/lib/phpunit/bootstrap.php
index aff8506868e..9663b59f1ad 100644
--- a/lib/phpunit/bootstrap.php
+++ b/lib/phpunit/bootstrap.php
@@ -186,7 +186,7 @@ $allowed = array('wwwroot', 'dataroot', 'dirroot', 'admin', 'directorypermission
                  'dbtype', 'dblibrary', 'dbhost', 'dbname', 'dbuser', 'dbpass', 'prefix', 'dboptions',
                  'proxyhost', 'proxyport', 'proxytype', 'proxyuser', 'proxypassword', 'proxybypass', // keep proxy settings from config.php
                  'altcacheconfigpath', 'pathtogs', 'pathtodu', 'aspellpath', 'pathtodot',
-                 'pathtounoconv'
+                 'pathtounoconv', 'alternative_file_system_class'
                 );
 $productioncfg = (array)$CFG;
 $CFG = new stdClass();
-- 
2.17.1


From 101e5c408db45f0647e0820dc71729bdd03c20b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Mudr=C3=A1k?= <david@moodle.com>
Date: Fri, 24 Mar 2017 22:16:33 +0100
Subject: [PATCH 4/7] MDL-53240 filetypes: Introduce the filetypes_util helper
 class

---
 lang/en/mimetypes.php                  |   1 +
 lib/form/classes/filetypes_util.php    | 252 +++++++++++++++++++++++++
 lib/form/tests/filetypes_util_test.php | 168 +++++++++++++++++
 3 files changed, 421 insertions(+)
 create mode 100644 lib/form/classes/filetypes_util.php
 create mode 100644 lib/form/tests/filetypes_util_test.php

diff --git a/lang/en/mimetypes.php b/lang/en/mimetypes.php
index f391d1e2103..b81e352d5ad 100644
--- a/lang/en/mimetypes.php
+++ b/lang/en/mimetypes.php
@@ -35,6 +35,7 @@
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['any'] = 'All file types';
 $string['application/msword'] = 'Word document';
 $string['application/pdf'] = 'PDF document';
 $string['application/vnd.moodle.backup'] = 'Moodle backup';
diff --git a/lib/form/classes/filetypes_util.php b/lib/form/classes/filetypes_util.php
new file mode 100644
index 00000000000..c8660809925
--- /dev/null
+++ b/lib/form/classes/filetypes_util.php
@@ -0,0 +1,252 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Provides the {@link core_form\filetypes_util} class.
+ *
+ * @package     core_form
+ * @copyright   2017 David Mudrák <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_form;
+
+use core_collator;
+use core_filetypes;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Utility class for handling with file types in the forms.
+ *
+ * This class is supposed to serve as a helper class for {@link MoodleQuickForm_filetypes}
+ * and {@link admin_setting_filetypes} classes.
+ *
+ * The file types can be specified in a syntax compatible with what filepicker
+ * and filemanager support via the "accepted_types" option: a list of extensions
+ * (e.g. ".doc"), mimetypes ("image/png") or groups ("audio").
+ *
+ * @copyright 2017 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class filetypes_util {
+
+    /** @var array Cache of all file type groups for the {@link self::get_groups_info()}. */
+    protected $cachegroups = null;
+
+    /**
+     * Converts the argument into an array (list) of file types.
+     *
+     * The list can be separated by whitespace, end of lines, commas, colons and semicolons.
+     * Empty values are not returned. Values are converted to lowercase.
+     * Duplicates are removed. Glob evaluation is not supported.
+     *
+     * The return value can be used as the accepted_types option for the filepicker.
+     *
+     * @param string|array $extensions list of file extensions, groups or mimetypes
+     * @return array of strings
+     */
+    public function normalize_file_types($types) {
+
+        if ($types === '') {
+            return [];
+        }
+
+        // Turn string into a list.
+        if (!is_array($types)) {
+            $types = preg_split('/[\s,;:"\']+/', $types, null, PREG_SPLIT_NO_EMPTY);
+        }
+
+        // Fix whitespace and normalize the syntax a bit.
+        foreach ($types as $i => $type) {
+            $type = str_replace('*.', '.', $type);
+            $type = strtolower($type);
+            $type = trim($type);
+
+            if ($type === '*') {
+                return ['*'];
+            }
+
+            $types[$i] = $type;
+        }
+
+        // Do not make the user think that globs (like ".doc?") would work.
+        foreach ($types as $i => $type) {
+            if (strpos($type, '*') !== false or strpos($type, '?') !== false) {
+                unset($types[$i]);
+            }
+        }
+
+        foreach ($types as $i => $type) {
+            if (substr($type, 0, 1) === '.') {
+                // It looks like an extension.
+                $type = '.'.ltrim($type, '.');
+                $types[$i] = clean_param($type, PARAM_FILE);
+            } else if ($this->looks_like_mimetype($type)) {
+                // All good, it looks like a mimetype.
+            } else if ($this->is_filetype_group($type)) {
+                // All good, it is a known type group.
+            } else {
+                // We assume the user typed something like "png" so we consider
+                // it an extension.
+                $types[$i] = '.'.$type;
+            }
+        }
+
+        $types = array_filter($types, 'strlen');
+        $types = array_keys(array_flip($types));
+
+        return $types;
+    }
+
+    /**
+     * Does the given file type looks like a valid MIME type?
+     *
+     * This does not check of the MIME type is actually registered here/known.
+     *
+     * @param string $type
+     * @return bool
+     */
+    public function looks_like_mimetype($type) {
+        return (bool)preg_match('~^[-\.a-z0-9]+/[a-z0-9]+([-\.\+][a-z0-9]+)*$~', $type);
+    }
+
+    /**
+     * Is the given string a known filetype group?
+     *
+     * @param string $type
+     * @return bool|object false or the group info
+     */
+    public function is_filetype_group($type) {
+
+        $info = $this->get_groups_info();
+
+        if (isset($info[$type])) {
+            return $info[$type];
+
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Provides a list of all known file type groups and their properties.
+     *
+     * @return array
+     */
+    public function get_groups_info() {
+
+        if ($this->cachegroups !== null) {
+            return $this->cachegroups;
+        }
+
+        $groups = [];
+
+        foreach (core_filetypes::get_types() as $ext => $info) {
+            if (isset($info['groups']) && is_array($info['groups'])) {
+                foreach ($info['groups'] as $group) {
+                    if (!isset($groups[$group])) {
+                        $groups[$group] = (object) [
+                            'extensions' => [],
+                            'mimetypes' => [],
+                        ];
+                    }
+                    $groups[$group]->extensions['.'.$ext] = true;
+                    if (isset($info['type'])) {
+                        $groups[$group]->mimetypes[$info['type']] = true;
+                    }
+                }
+            }
+        }
+
+        foreach ($groups as $group => $info) {
+            $info->extensions = array_keys($info->extensions);
+            $info->mimetypes = array_keys($info->mimetypes);
+        }
+
+        $this->cachegroups = $groups;
+        return $this->cachegroups;
+    }
+
+    /**
+     * Return a human readable name of the filetype group.
+     *
+     * @param string $group
+     * @return string
+     */
+    public function get_group_description($group) {
+
+		if (get_string_manager()->string_exists('group:'.$group, 'core_mimetypes')) {
+			return get_string('group:'.$group, 'core_mimetypes');
+        } else {
+            return s($group);
+        }
+    }
+
+    /**
+     * Describe the list of file types for human user.
+     *
+     * Given the list of file types, return a list of human readable
+     * descriptive names of relevant groups, types or file formats.
+     *
+     * @param string|array $types
+     * @return object
+     */
+    public function describe_file_types($types) {
+
+        $descriptions = [];
+        $types = $this->normalize_file_types($types);
+
+        foreach ($types as $type) {
+            if ($type === '*') {
+                $desc = get_string('any', 'core_mimetypes');
+                $descriptions[$desc] = [];
+            } else if ($group = $this->is_filetype_group($type)) {
+                $desc = $this->get_group_description($type);
+                $descriptions[$desc] = $group->extensions;
+
+            } else if ($this->looks_like_mimetype($type)) {
+                $desc = get_mimetype_description($type);
+                $descriptions[$desc] = file_get_typegroup('extension', [$type]);
+
+            } else {
+                $desc = get_mimetype_description(['filename' => 'fakefile'.$type]);
+                if (isset($descriptions[$desc])) {
+                    $descriptions[$desc][] = $type;
+                } else {
+                    $descriptions[$desc] = [$type];
+                }
+            }
+        }
+
+        $data = [];
+
+        foreach ($descriptions as $desc => $exts) {
+            sort($exts);
+            $data[] = (object)[
+                'description' => $desc,
+                'extensions' => join(' ', $exts),
+            ];
+        }
+
+        core_collator::asort_objects_by_property($data, 'description', core_collator::SORT_NATURAL);
+
+        return (object)[
+            'hasdescriptions' => !empty($data),
+            'descriptions' => array_values($data),
+        ];
+    }
+}
diff --git a/lib/form/tests/filetypes_util_test.php b/lib/form/tests/filetypes_util_test.php
new file mode 100644
index 00000000000..323e8381a93
--- /dev/null
+++ b/lib/form/tests/filetypes_util_test.php
@@ -0,0 +1,168 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Provides the {@link core_form\filetypes_util_testcase} class.
+ *
+ * @package     core_form
+ * @category    test
+ * @copyright   2017 David Mudrák <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_form;
+
+use advanced_testcase;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+/**
+ * Test cases for the {@link core_form\filetypes_util} class.
+ *
+ * @copyright 2017 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class filetypes_util_testcase extends advanced_testcase {
+
+    /**
+     * Test normalizing list of extensions.
+     */
+    public function test_normalize_file_types() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $this->assertSame(['.odt'], $util->normalize_file_types('.odt'));
+        $this->assertSame(['.odt'], $util->normalize_file_types('odt'));
+        $this->assertSame(['.odt'], $util->normalize_file_types('.ODT'));
+        $this->assertSame(['.doc', '.jpg', '.mp3'], $util->normalize_file_types('doc, jpg, mp3'));
+        $this->assertSame(['.doc', '.jpg', '.mp3'], $util->normalize_file_types(['.doc', '.jpg', '.mp3']));
+        $this->assertSame(['.doc', '.jpg', '.mp3'], $util->normalize_file_types('doc, *.jpg, mp3'));
+        $this->assertSame(['.doc', '.jpg', '.mp3'], $util->normalize_file_types(['doc ', ' JPG ', '.mp3']));
+        $this->assertSame(['.rtf', '.pdf', '.docx'],
+            $util->normalize_file_types("RTF,.pdf\n...DocX,,,;\rPDF\trtf ...Rtf"));
+        $this->assertSame(['.tgz', '.tar.gz'], $util->normalize_file_types('tgz,TAR.GZ tar.gz .tar.gz tgz TGZ'));
+        $this->assertSame(['.notebook'], $util->normalize_file_types('"Notebook":notebook;NOTEBOOK;,\'NoTeBook\''));
+        $this->assertSame([], $util->normalize_file_types(''));
+        $this->assertSame([], $util->normalize_file_types([]));
+        $this->assertSame(['.0'], $util->normalize_file_types(0));
+        $this->assertSame(['.0'], $util->normalize_file_types('0'));
+        $this->assertSame(['.odt'], $util->normalize_file_types('*.odt'));
+        $this->assertSame([], $util->normalize_file_types('.'));
+        $this->assertSame(['.foo'], $util->normalize_file_types('. foo'));
+        $this->assertSame(['*'], $util->normalize_file_types('*'));
+        $this->assertSame([], $util->normalize_file_types('*~'));
+        $this->assertSame(['.pdf', '.ps'], $util->normalize_file_types('pdf *.ps foo* *bar .r??'));
+        $this->assertSame(['*'], $util->normalize_file_types('pdf *.ps foo* * *bar .r??'));
+    }
+
+    /**
+     * Test MIME type formal recognition.
+     */
+    public function test_looks_like_mimetype() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $this->assertTrue($util->looks_like_mimetype('type/subtype'));
+        $this->assertTrue($util->looks_like_mimetype('type/x-subtype'));
+        $this->assertTrue($util->looks_like_mimetype('type/x-subtype+xml'));
+        $this->assertTrue($util->looks_like_mimetype('type/vnd.subtype.xml'));
+        $this->assertTrue($util->looks_like_mimetype('type/vnd.subtype+xml'));
+
+        $this->assertFalse($util->looks_like_mimetype('.gif'));
+        $this->assertFalse($util->looks_like_mimetype('audio'));
+        $this->assertFalse($util->looks_like_mimetype('foo/bar/baz'));
+    }
+
+    /**
+     * Test getting/checking group.
+     */
+    public function test_is_filetype_group() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $audio = $util->is_filetype_group('audio');
+        $this->assertNotFalse($audio);
+        $this->assertInternalType('array', $audio->extensions);
+        $this->assertInternalType('array', $audio->mimetypes);
+
+        $this->assertFalse($util->is_filetype_group('.gif'));
+        $this->assertFalse($util->is_filetype_group('somethingveryunlikelytoeverexist'));
+    }
+
+
+    /**
+     * Test describing list of extensions.
+     */
+    public function test_describe_file_types() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        force_current_language('en');
+
+        // Check that it is able to describe individual file extensions.
+        $desc = $util->describe_file_types('jpg .jpeg *.jpe PNG;.gif,  mudrd8mz');
+        $this->assertTrue($desc->hasdescriptions);
+
+        $desc = $desc->descriptions;
+        $this->assertEquals(4, count($desc));
+
+        $this->assertEquals('File', $desc[0]->description);
+        $this->assertEquals('.mudrd8mz', $desc[0]->extensions);
+
+        $this->assertEquals('Image (JPEG)', $desc[2]->description);
+        $this->assertContains('.jpg', $desc[2]->extensions);
+        $this->assertContains('.jpeg', $desc[2]->extensions);
+        $this->assertContains('.jpe', $desc[2]->extensions);
+
+        // Check that it can describe groups and mimetypes too.
+        $desc = $util->describe_file_types('audio text/plain');
+        $this->assertTrue($desc->hasdescriptions);
+
+        $desc = $desc->descriptions;
+        $this->assertEquals(2, count($desc));
+
+        $this->assertEquals('Audio files', $desc[0]->description);
+        $this->assertContains('.mp3', $desc[0]->extensions);
+        $this->assertContains('.wav', $desc[0]->extensions);
+        $this->assertContains('.ogg', $desc[0]->extensions);
+
+        $this->assertEquals('Text file', $desc[1]->description);
+        $this->assertContains('.txt', $desc[1]->extensions);
+
+        // Empty.
+        $desc = $util->describe_file_types('');
+        $this->assertFalse($desc->hasdescriptions);
+        $this->assertEmpty($desc->descriptions);
+
+        // Any.
+        $desc = $util->describe_file_types('*');
+        $this->assertTrue($desc->hasdescriptions);
+        $this->assertNotEmpty($desc->descriptions[0]->description);
+        $this->assertEmpty($desc->descriptions[0]->extensions);
+
+        // Unknown mimetype.
+        $desc = $util->describe_file_types('application/x-something-really-unlikely-ever-exist');
+        $this->assertTrue($desc->hasdescriptions);
+        $this->assertEquals('application/x-something-really-unlikely-ever-exist', $desc->descriptions[0]->description);
+        $this->assertEmpty($desc->descriptions[0]->extensions);
+    }
+}
-- 
2.17.1


From 3592c8a77d02b9aee9276a50f406c27156e291f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Mudr=C3=A1k?= <david@moodle.com>
Date: Wed, 29 Mar 2017 18:45:53 +0200
Subject: [PATCH 5/7] MDL-53240 filetypes: Add methods for generating the
 browser tree content

These new methods will be used to render the tree of file types groups
and extensions in the browser widget.

Co-Authored-By: John Okely <john@moodle.com>
---
 lang/en/form.php                       |   2 +
 lang/en/mimetypes.php                  |   1 -
 lib/form/classes/filetypes_util.php    | 185 ++++++++++++++++++++++++-
 lib/form/tests/filetypes_util_test.php | 141 +++++++++++++++++++
 4 files changed, 327 insertions(+), 2 deletions(-)

diff --git a/lang/en/form.php b/lang/en/form.php
index 1f70f674044..45e640c6687 100644
--- a/lang/en/form.php
+++ b/lang/en/form.php
@@ -39,6 +39,8 @@ $string['err_nopunctuation'] = 'You must enter no punctuation characters here.';
 $string['err_numeric'] = 'You must enter a number here.';
 $string['err_rangelength'] = 'You must enter between {$a->format[0]} and {$a->format[1]} characters here.';
 $string['err_required'] = 'You must supply a value here.';
+$string['filetypesany'] = 'All file types';
+$string['filetypesothers'] = 'Other files';
 $string['general'] = 'General';
 $string['hideadvanced'] = 'Hide advanced';
 $string['hour'] = 'Hour';
diff --git a/lang/en/mimetypes.php b/lang/en/mimetypes.php
index b81e352d5ad..f391d1e2103 100644
--- a/lang/en/mimetypes.php
+++ b/lang/en/mimetypes.php
@@ -35,7 +35,6 @@
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-$string['any'] = 'All file types';
 $string['application/msword'] = 'Word document';
 $string['application/pdf'] = 'PDF document';
 $string['application/vnd.moodle.backup'] = 'Moodle backup';
diff --git a/lib/form/classes/filetypes_util.php b/lib/form/classes/filetypes_util.php
index c8660809925..7d6f1369837 100644
--- a/lib/form/classes/filetypes_util.php
+++ b/lib/form/classes/filetypes_util.php
@@ -212,7 +212,7 @@ class filetypes_util {
 
         foreach ($types as $type) {
             if ($type === '*') {
-                $desc = get_string('any', 'core_mimetypes');
+                $desc = get_string('filetypesany', 'core_form');
                 $descriptions[$desc] = [];
             } else if ($group = $this->is_filetype_group($type)) {
                 $desc = $this->get_group_description($type);
@@ -249,4 +249,187 @@ class filetypes_util {
             'descriptions' => array_values($data),
         ];
     }
+
+    /**
+     * Prepares data for the filetypes-browser.mustache
+     *
+     * @param string|array $onlytypes Allow selection from these file types only; for example 'web_image'.
+     * @param bool allowall Allow to select 'All file types'. Does not apply with onlytypes are set.
+     * @param $current string|array Current values that should be selected.
+     * @return object
+     */
+    public function data_for_browser($onlytypes=null, $allowall=true, $current=null) {
+
+        $groups = [];
+        $current = $this->normalize_file_types($current);
+
+        // Firstly populate the tree of extensions categorized into groups.
+
+        foreach ($this->get_groups_info() as $groupkey => $groupinfo) {
+            if (empty($groupinfo->extensions)) {
+                continue;
+            }
+
+            $group = (object) [
+                'key' => $groupkey,
+                'name' => $this->get_group_description($groupkey),
+                'selectable' => true,
+                'selected' => in_array($groupkey, $current),
+                'ext' => implode(' ', $groupinfo->extensions),
+                'expanded' => false,
+            ];
+
+            $types = [];
+
+            foreach ($groupinfo->extensions as $extension) {
+                if ($onlytypes && !$this->is_whitelisted($extension, $onlytypes)) {
+                    $group->selectable = false;
+                    $group->expanded = true;
+                    $group->ext = '';
+                    continue;
+                }
+
+                $desc = get_mimetype_description(['filename' => 'fakefile'.$extension]);
+
+                if ($selected = in_array($extension, $current)) {
+                    $group->expanded = true;
+                }
+
+                $types[] = (object) [
+                    'key' => $extension,
+                    'name' => get_mimetype_description(['filename' => 'fakefile'.$extension]),
+                    'selected' => $selected,
+                    'ext' => $extension,
+                ];
+            }
+
+            if (empty($types)) {
+                continue;
+            }
+
+            core_collator::asort_objects_by_property($types, 'name', core_collator::SORT_NATURAL);
+
+            $group->types = array_values($types);
+            $groups[] = $group;
+        }
+
+        core_collator::asort_objects_by_property($groups, 'name', core_collator::SORT_NATURAL);
+
+        // Append all other uncategorized extensions.
+
+        $others = [];
+
+        foreach (core_filetypes::get_types() as $extension => $info) {
+            $extension = '.'.$extension;
+            if ($onlytypes && !$this->is_whitelisted($extension, $onlytypes)) {
+                continue;
+            }
+            if (!isset($info['groups']) || empty($info['groups'])) {
+                $others[] = (object) [
+                    'key' => $extension,
+                    'name' => get_mimetype_description(['filename' => 'fakefile'.$extension]),
+                    'selected' => in_array($extension, $current),
+                    'ext' => $extension,
+                ];
+            }
+        }
+
+        core_collator::asort_objects_by_property($others, 'name', core_collator::SORT_NATURAL);
+
+        if (!empty($others)) {
+            $groups[] = (object) [
+                'key' => '',
+                'name' => get_string('filetypesothers', 'core_form'),
+                'selectable' => false,
+                'selected' => false,
+                'ext' => '',
+                'types' => array_values($others),
+                'expanded' => true,
+            ];
+        }
+
+        if (empty($onlytypes) and $allowall) {
+            array_unshift($groups, (object) [
+                'key' => '*',
+                'name' => get_string('filetypesany', 'core_form'),
+                'selectable' => true,
+                'selected' => in_array('*', $current),
+                'ext' => null,
+                'types' => [],
+                'expanded' => false,
+            ]);
+        }
+
+        $groups = array_values($groups);
+
+        return $groups;
+    }
+
+    /**
+     * Expands the file types into the list of file extensions.
+     *
+     * The groups and mimetypes are expanded into the list of their associated file
+     * extensions. Depending on the $keepgroups and $keepmimetypes, the groups
+     * and mimetypes themselves are either kept in the list or removed.
+     *
+     * @param string|array $types
+     * @param bool $keepgroups Keep the group item in the list after expansion
+     * @param bool $keepmimetypes Keep the mimetype item in the list after expansion
+     * @return array list of extensions and eventually groups and types
+     */
+    public function expand($types, $keepgroups=false, $keepmimetypes=false) {
+
+        $expanded = [];
+
+        foreach ($this->normalize_file_types($types) as $type) {
+            if ($group = $this->is_filetype_group($type)) {
+                foreach ($group->extensions as $ext) {
+                    $expanded[$ext] = true;
+                }
+                if ($keepgroups) {
+                    $expanded[$type] = true;
+                }
+
+            } else if ($this->looks_like_mimetype($type)) {
+                // A mime type expands to the associated extensions.
+                foreach (file_get_typegroup('extension', [$type]) as $ext) {
+                    $expanded[$ext] = true;
+                }
+                if ($keepmimetypes) {
+                    $expanded[$type] = true;
+                }
+
+            } else {
+                // Single extension expands to itself.
+                $expanded[$type] = true;
+            }
+        }
+
+        return array_keys($expanded);
+    }
+
+    /**
+     * Should the given file type be considered as a part of the given whitelist.
+     *
+     * If multiple types are provided, all of them must be part of the
+     * whitelist.
+     *
+     * @param string $types One or more types in a string (space , or ; separated)
+     * @param string|array $whitelist an array or string of whitelisted types
+     * @return boolean
+     */
+    public function is_whitelisted($types, $whitelist) {
+
+        $whitelistedtypes = $this->expand($whitelist, true, true);
+
+        if (empty($whitelistedtypes) || $whitelistedtypes == ['*']) {
+            return true;
+        }
+
+        $giventypes = $this->normalize_file_types($types);
+
+        $intersection = array_intersect($giventypes, $whitelistedtypes);
+
+        return !empty($intersection);
+    }
 }
diff --git a/lib/form/tests/filetypes_util_test.php b/lib/form/tests/filetypes_util_test.php
index 323e8381a93..91e17cf9aeb 100644
--- a/lib/form/tests/filetypes_util_test.php
+++ b/lib/form/tests/filetypes_util_test.php
@@ -165,4 +165,145 @@ class filetypes_util_testcase extends advanced_testcase {
         $this->assertEquals('application/x-something-really-unlikely-ever-exist', $desc->descriptions[0]->description);
         $this->assertEmpty($desc->descriptions[0]->extensions);
     }
+
+    /**
+     * Test expanding mime types into extensions.
+     */
+    public function test_expand() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $this->assertSame([], $util->expand(''));
+
+        $expanded = $util->expand('document .cdr text/plain');
+        $this->assertNotContains('document', $expanded);
+        $this->assertNotContains('text/plain', $expanded);
+        $this->assertContains('.doc', $expanded);
+        $this->assertContains('.odt', $expanded);
+        $this->assertContains('.txt', $expanded);
+        $this->assertContains('.cdr', $expanded);
+
+        $expanded = $util->expand('document .cdr text/plain', true, false);
+        $this->assertContains('document', $expanded);
+        $this->assertNotContains('text/plain', $expanded);
+        $this->assertContains('.doc', $expanded);
+        $this->assertContains('.odt', $expanded);
+        $this->assertContains('.txt', $expanded);
+        $this->assertContains('.cdr', $expanded);
+
+        $expanded = $util->expand('document .cdr text/plain', false, true);
+        $this->assertNotContains('document', $expanded);
+        $this->assertContains('text/plain', $expanded);
+        $this->assertContains('.doc', $expanded);
+        $this->assertContains('.odt', $expanded);
+        $this->assertContains('.txt', $expanded);
+        $this->assertContains('.cdr', $expanded);
+
+        $this->assertSame([], $util->expand('foo/bar', true, false));
+        $this->assertSame(['foo/bar'], $util->expand('foo/bar', true, true));
+    }
+
+    /**
+     * Test checking that a type is among others.
+     */
+    public function test_is_whitelisted() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        // These should be intuitively true.
+        $this->assertTrue($util->is_whitelisted('txt', 'text/plain'));
+        $this->assertTrue($util->is_whitelisted('txt', 'doc txt rtf'));
+        $this->assertTrue($util->is_whitelisted('.txt', '.doc;.txt;.rtf'));
+        $this->assertTrue($util->is_whitelisted('audio', 'text/plain audio video'));
+        $this->assertTrue($util->is_whitelisted('text/plain', 'text/plain audio video'));
+        $this->assertTrue($util->is_whitelisted('jpg jpe jpeg', 'image/jpeg'));
+
+        // These should be intuitively false.
+        $this->assertFalse($util->is_whitelisted('.gif', 'text/plain'));
+
+        // Not all text/plain formats are in the document group.
+        $this->assertFalse($util->is_whitelisted('text/plain', 'document'));
+
+        // Not all documents (and also the group itself) is not a plain text.
+        $this->assertFalse($util->is_whitelisted('document', 'text/plain'));
+
+        // Any type is included if the filter is empty.
+        $this->assertTrue($util->is_whitelisted('txt', ''));
+        $this->assertTrue($util->is_whitelisted('txt', '*'));
+    }
+
+    /**
+     * Test populating the tree for the browser.
+     */
+    public function test_data_for_browser() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $data = $util->data_for_browser();
+        $this->assertContainsOnly('object', $data);
+        foreach ($data as $group) {
+            $this->assertObjectHasAttribute('key', $group);
+            $this->assertObjectHasAttribute('types', $group);
+            if ($group->key !== '') {
+                $this->assertTrue($group->selectable);
+            }
+        }
+
+        // All these three files are in both "image" and also "web_image"
+        // groups. We display both groups.
+        $data = $util->data_for_browser('jpg png gif', true, '.gif');
+        $this->assertEquals(2, count($data));
+        $this->assertTrue($data[0]->key !== $data[1]->key);
+        foreach ($data as $group) {
+            $this->assertTrue(($group->key === 'image' || $group->key === 'web_image'));
+            $this->assertEquals(3, count($group->types));
+            $this->assertFalse($group->selectable);
+            foreach ($group->types as $ext) {
+                if ($ext->key === '.gif') {
+                    $this->assertTrue($ext->selected);
+                } else {
+                    $this->assertFalse($ext->selected);
+                }
+            }
+        }
+
+        // There is a group web_image which is a subset of the group image. The
+        // file extensions that fall into both groups will be displayed twice.
+        $data = $util->data_for_browser('web_image');
+        foreach ($data as $group) {
+            $this->assertTrue(($group->key === 'image' || $group->key === 'web_image'));
+        }
+
+        // Check that "All file types" are displayed first.
+        $data = $util->data_for_browser();
+        $group = array_shift($data);
+        $this->assertEquals('*', $group->key);
+
+        // Check that "All file types" is not displayed if should not.
+        $data = $util->data_for_browser(null, false);
+        $group = array_shift($data);
+        $this->assertNotEquals('*', $group->key);
+
+        // Groups with an extension selected start expanded. The "Other files"
+        // starts expanded. The rest start collapsed.
+        $data = $util->data_for_browser(null, false, '.png');
+        foreach ($data as $group) {
+            if ($group->key === 'document') {
+                $this->assertfalse($group->expanded);
+            } else if ($group->key === '') {
+                $this->assertTrue($group->expanded);
+            }
+            foreach ($group->types as $ext) {
+                foreach ($group->types as $ext) {
+                    if ($ext->key === '.png') {
+                        $this->assertTrue($ext->selected);
+                        $this->assertTrue($group->expanded);
+                    }
+                }
+            }
+        }
+    }
 }
-- 
2.17.1


From d3236143f026d2ed1d9628902e65bd11167da281 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Mudr=C3=A1k?= <david@moodle.com>
Date: Wed, 5 Apr 2017 01:21:48 +0200
Subject: [PATCH 6/7] MDL-53240 filetypes: Introduce admin_setting_filetypes
 class

This new type of admin settings makes use of the filetypes browser but
for the admin settings.
---
 admin/templates/setting_filetypes.mustache |  52 +++++++++
 lang/en/form.php                           |   1 +
 lib/adminlib.php                           | 117 +++++++++++++++++++++
 lib/form/classes/filetypes_util.php        |  29 +++--
 lib/form/tests/filetypes_util_test.php     |  36 +++++++
 5 files changed, 229 insertions(+), 6 deletions(-)
 create mode 100644 admin/templates/setting_filetypes.mustache

diff --git a/admin/templates/setting_filetypes.mustache b/admin/templates/setting_filetypes.mustache
new file mode 100644
index 00000000000..7075ea25a4b
--- /dev/null
+++ b/admin/templates/setting_filetypes.mustache
@@ -0,0 +1,52 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_admin/setting_filetypes
+
+    Renders the admin_setting_filetypes setting element.
+
+    Context variables required for this template:
+    * id - element id
+    * name - form element name
+    * value - element value
+    * descriptions - data for the core_form/filetypes-descriptions template
+
+    Example context (json):
+    {
+        "id": "test0",
+        "name": "test",
+        "value": ".jpg,.gif",
+        "descriptions": {
+            "hasdescriptions": true,
+            "descriptions": [
+                {
+                    "description": "Image (JPEG)",
+                    "extensions": ".jpeg .jpe .jpg"
+                },
+                {
+                    "description": "Image (GIF)",
+                    "extensions": ".gif"
+                }
+            ]
+        }
+    }
+}}
+<div class="form-text defaultsnext">
+    <input type="text" name="{{name}}" value="{{value}}" size="30" id="{{id}}" class="text-ltr">
+    <span data-filetypesbrowser="{{id}}"></span>
+    <div data-filetypesdescriptions="{{id}}">{{#descriptions}}{{>core_form/filetypes-descriptions}}{{/descriptions}}</div>
+</div>
diff --git a/lang/en/form.php b/lang/en/form.php
index 45e640c6687..4cff247d401 100644
--- a/lang/en/form.php
+++ b/lang/en/form.php
@@ -40,6 +40,7 @@ $string['err_numeric'] = 'You must enter a number here.';
 $string['err_rangelength'] = 'You must enter between {$a->format[0]} and {$a->format[1]} characters here.';
 $string['err_required'] = 'You must supply a value here.';
 $string['filetypesany'] = 'All file types';
+$string['filetypesnotwhitelisted'] = 'These file types are not allowed here: {$a}';
 $string['filetypesothers'] = 'Other files';
 $string['general'] = 'General';
 $string['hideadvanced'] = 'Hide advanced';
diff --git a/lib/adminlib.php b/lib/adminlib.php
index d4636287963..34e90b6ce96 100644
--- a/lib/adminlib.php
+++ b/lib/adminlib.php
@@ -9782,3 +9782,120 @@ class admin_setting_searchsetupinfo extends admin_setting {
     }
 
 }
+
+/**
+ * Administration setting to define a list of file types.
+ *
+ * @copyright 2016 Jonathon Fowler <fowlerj@usq.edu.au>
+ * @copyright 2017 David Mudrák <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class admin_setting_filetypes extends admin_setting_configtext {
+
+    /** @var array Allow selection from these file types only. */
+    protected $onlytypes = [];
+
+    /** @var bool Allow selection of 'All file types' (will be stored as '*'). */
+    protected $allowall = true;
+
+    /** @var core_form\filetypes_util instance to use as a helper. */
+    protected $util = null;
+
+    /**
+     * Constructor.
+     *
+     * @param string $name Unique ascii name like 'mycoresetting' or 'myplugin/mysetting'
+     * @param string $visiblename Localised label of the setting
+     * @param string $description Localised description of the setting
+     * @param string $defaultsetting Default setting value.
+     * @param array $options Setting widget options, an array with optional keys:
+     *   'onlytypes' => array Allow selection from these file types only; for example ['onlytypes' => ['web_image']].
+     *   'allowall' => bool Allow to select 'All file types', defaults to true. Does not apply if onlytypes are set.
+     */
+    public function __construct($name, $visiblename, $description, $defaultsetting = '', array $options = []) {
+
+        parent::__construct($name, $visiblename, $description, $defaultsetting, PARAM_RAW);
+
+        if (array_key_exists('onlytypes', $options) && is_array($options['onlytypes'])) {
+            $this->onlytypes = $options['onlytypes'];
+        }
+
+        if (!$this->onlytypes && array_key_exists('allowall', $options)) {
+            $this->allowall = (bool)$options['allowall'];
+        }
+
+        $this->util = new \core_form\filetypes_util();
+    }
+
+    /**
+     * Normalize the user's input and write it to the database as comma separated list.
+     *
+     * Comma separated list as a text representation of the array was chosen to
+     * make this compatible with how the $CFG->courseoverviewfilesext values are stored.
+     *
+     * @param string $data Value submitted by the admin.
+     * @return string Epty string if all good, error message otherwise.
+     */
+    public function write_setting($data) {
+        return parent::write_setting(implode(',', $this->util->normalize_file_types($data)));
+    }
+
+    /**
+     * Validate data before storage
+     *
+     * @param string $data The setting values provided by the admin
+     * @return bool|string True if ok, the string if error found
+     */
+    public function validate($data) {
+
+        // No need to call parent's validation here as we are PARAM_RAW.
+
+        if ($this->util->is_whitelisted($data, $this->onlytypes)) {
+            return true;
+
+        } else {
+            $troublemakers = $this->util->get_not_whitelisted($data, $this->onlytypes);
+            return get_string('filetypesnotwhitelisted', 'core_form', implode(' ', $troublemakers));
+        }
+    }
+
+    /**
+     * Return an HTML string for the setting element.
+     *
+     * @param string $data The current setting value
+     * @param string $query Admin search query to be highlighted
+     * @return string HTML to be displayed
+     */
+    public function output_html($data, $query='') {
+        global $OUTPUT, $PAGE;
+
+        $default = $this->get_defaultsetting();
+        $context = (object) [
+            'id' => $this->get_id(),
+            'name' => $this->get_full_name(),
+            'value' => $data,
+            'descriptions' => $this->util->describe_file_types($data),
+        ];
+        $element = $OUTPUT->render_from_template('core_admin/setting_filetypes', $context);
+
+        $PAGE->requires->js_call_amd('core_form/filetypes', 'init', [
+            $this->get_id(),
+            $this->visiblename,
+            $this->onlytypes,
+            $this->allowall,
+        ]);
+
+        return format_admin_setting($this, $this->visiblename, $element, $this->description, true, '', $default, $query);
+    }
+
+    /**
+     * Should the values be always displayed in LTR mode?
+     *
+     * We always return true here because these values are not RTL compatible.
+     *
+     * @return bool True because these values are not RTL compatible.
+     */
+    public function get_force_ltr() {
+        return true;
+    }
+}
diff --git a/lib/form/classes/filetypes_util.php b/lib/form/classes/filetypes_util.php
index 7d6f1369837..6fa1861f4f8 100644
--- a/lib/form/classes/filetypes_util.php
+++ b/lib/form/classes/filetypes_util.php
@@ -412,24 +412,41 @@ class filetypes_util {
      * Should the given file type be considered as a part of the given whitelist.
      *
      * If multiple types are provided, all of them must be part of the
-     * whitelist.
+     * whitelist. Empty type is part of any whitelist. Any type is part of an
+     * empty whitelist.
      *
-     * @param string $types One or more types in a string (space , or ; separated)
-     * @param string|array $whitelist an array or string of whitelisted types
+     * @param string|array $types File types to be checked
+     * @param string|array $whitelist An array or string of whitelisted types
      * @return boolean
      */
     public function is_whitelisted($types, $whitelist) {
+        return empty($this->get_not_whitelisted($types, $whitelist));
+    }
+
+    /**
+     * Returns all types that are not part of the give whitelist.
+     *
+     * This is similar check to the {@link self::is_whitelisted()} but this one
+     * actually returns the extra types.
+     *
+     * @param string|array $types File types to be checked
+     * @param string|array $whitelist An array or string of whitelisted types
+     * @return array Types not present in the whitelist
+     */
+    public function get_not_whitelisted($types, $whitelist) {
 
         $whitelistedtypes = $this->expand($whitelist, true, true);
 
         if (empty($whitelistedtypes) || $whitelistedtypes == ['*']) {
-            return true;
+            return [];
         }
 
         $giventypes = $this->normalize_file_types($types);
 
-        $intersection = array_intersect($giventypes, $whitelistedtypes);
+        if (empty($giventypes)) {
+            return [];
+        }
 
-        return !empty($intersection);
+        return array_diff($giventypes, $whitelistedtypes);
     }
 }
diff --git a/lib/form/tests/filetypes_util_test.php b/lib/form/tests/filetypes_util_test.php
index 91e17cf9aeb..8530ab6b594 100644
--- a/lib/form/tests/filetypes_util_test.php
+++ b/lib/form/tests/filetypes_util_test.php
@@ -219,6 +219,7 @@ class filetypes_util_testcase extends advanced_testcase {
         $this->assertTrue($util->is_whitelisted('audio', 'text/plain audio video'));
         $this->assertTrue($util->is_whitelisted('text/plain', 'text/plain audio video'));
         $this->assertTrue($util->is_whitelisted('jpg jpe jpeg', 'image/jpeg'));
+        $this->assertTrue($util->is_whitelisted(['jpg', 'jpe', '.png'], 'image'));
 
         // These should be intuitively false.
         $this->assertFalse($util->is_whitelisted('.gif', 'text/plain'));
@@ -229,9 +230,44 @@ class filetypes_util_testcase extends advanced_testcase {
         // Not all documents (and also the group itself) is not a plain text.
         $this->assertFalse($util->is_whitelisted('document', 'text/plain'));
 
+        // This may look wrong at the first sight as you might expect that the
+        // mimetype should simply map to an extension ...
+        $this->assertFalse($util->is_whitelisted('image/jpeg', '.jpg'));
+
+        // But it is principally same situation as this (there is no 1:1 mapping).
+        $this->assertFalse($util->is_whitelisted('.c', '.txt'));
+        $this->assertTrue($util->is_whitelisted('.txt .c', 'text/plain'));
+        $this->assertFalse($util->is_whitelisted('text/plain', '.c'));
+
         // Any type is included if the filter is empty.
         $this->assertTrue($util->is_whitelisted('txt', ''));
         $this->assertTrue($util->is_whitelisted('txt', '*'));
+
+        // Empty value is part of any whitelist.
+        $this->assertTrue($util->is_whitelisted('', '.txt'));
+    }
+
+    /**
+     * Test getting types not present in a whitelist.
+     */
+    public function test_get_not_whitelisted() {
+
+        $this->resetAfterTest(true);
+        $util = new filetypes_util();
+
+        $this->assertEmpty($util->get_not_whitelisted('txt', 'text/plain'));
+        $this->assertEmpty($util->get_not_whitelisted('txt', '.doc .txt .rtf'));
+        $this->assertEmpty($util->get_not_whitelisted('txt', 'text/plain'));
+        $this->assertEmpty($util->get_not_whitelisted(['jpg', 'jpe', 'jpeg'], 'image/jpeg'));
+        $this->assertEmpty($util->get_not_whitelisted('', 'foo/bar'));
+        $this->assertEmpty($util->get_not_whitelisted('.foobar', ''));
+        $this->assertEmpty($util->get_not_whitelisted('.foobar', '*'));
+
+        // Returned list is normalized so extensions have the dot added.
+        $this->assertContains('.exe', $util->get_not_whitelisted('exe', '.c .h'));
+
+        // If this looks wrong to you, see {@link test_is_whitelisted()} for more details on this behaviour.
+        $this->assertContains('image/jpeg', $util->get_not_whitelisted('image/jpeg', '.jpg .jpeg'));
     }
 
     /**
-- 
2.17.1


From 590eac8f1f0eff04e1d8bc969ccd69f3279ccdba Mon Sep 17 00:00:00 2001
From: Mikhail Golenkov <mikhailgolenkov@catalyst-au.net>
Date: Fri, 3 Jul 2020 13:20:38 +1000
Subject: [PATCH 7/7] MDL-53240 filetypes: Introduce the form element to
 specify plugin types

This is a stripped down version of the original Jonathon's element without the actual JS selector. The options allowall and onlytypes are not fully supported right now yet.
---
 lib/form/filetypes.php                        | 194 ++++++++++++++++++
 .../templates/filetypes-descriptions.mustache |  60 ++++++
 lib/formslib.php                              |   1 +
 3 files changed, 255 insertions(+)
 create mode 100644 lib/form/filetypes.php
 create mode 100644 lib/form/templates/filetypes-descriptions.mustache

diff --git a/lib/form/filetypes.php b/lib/form/filetypes.php
new file mode 100644
index 00000000000..cb0794ee21a
--- /dev/null
+++ b/lib/form/filetypes.php
@@ -0,0 +1,194 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Provides the {@link MoodleQuickForm_filetypes} class.
+ *
+ * @package   core_form
+ * @copyright 2016 Jonathon Fowler <fowlerj@usq.edu.au>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use core_form\filetypes_util;
+
+defined('MOODLE_INTERNAL') || die;
+
+global $CFG;
+require_once($CFG->dirroot.'/lib/form/group.php');
+
+/**
+ * File types and type groups selection form element.
+ *
+ * @package   core_form
+ * @category  form
+ * @copyright 2016 Jonathon Fowler <fowlerj@usq.edu.au>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class MoodleQuickForm_filetypes extends MoodleQuickForm_group {
+
+    /** @var array Allow selection from these file types only. */
+    protected $onlytypes = [];
+
+    /** @var bool Allow selection of 'All file types' (will be stored as '*'). */
+    protected $allowall = true;
+
+    /** @var core_form\filetypes_util instance to use as a helper. */
+    protected $util = null;
+
+    /**
+     * Constructor
+     *
+     * @param string $elementName Element's name
+     * @param string $elementLabel Label(s) for an element
+     * @param array $options element options:
+     *   'onlytypes': Allow selection from these file types only; for example ['onlytypes' => ['web_image']].
+     *   'allowall': Allow to select 'All file types', defaults to true. Does not apply with onlytypes are set.
+     * @param array|string $attributes Either a typical HTML attribute string or an associative array
+     */
+    public function __construct($elementName = null, $elementLabel = null, $options = null, $attributes = null) {
+
+        parent::__construct($elementName, $elementLabel);
+        $this->_type = 'filetypes';
+
+        // Hard-frozen elements do not get the name populated automatically,
+        // which leads to PHP notice. Add it explicitly here.
+        $this->setAttributes(array('name' => $elementName));
+        $this->updateAttributes($attributes);
+
+        if (is_array($options) && $options) {
+            if (array_key_exists('onlytypes', $options) && is_array($options['onlytypes'])) {
+                $this->onlytypes = $options['onlytypes'];
+            }
+            if (!$this->onlytypes && array_key_exists('allowall', $options)) {
+                $this->allowall = (bool)$options['allowall'];
+            }
+        }
+
+        $this->util = new filetypes_util();
+    }
+
+    /**
+     * Assemble the elements of the form control.
+     */
+    public function _createElements() {
+
+        $this->_generateId();
+
+        $this->setElements([
+            $this->createFormElement('text', 'filetypes', '', [
+                'id' => $this->getAttribute('id'),
+            ]),
+
+            $this->createFormElement('static', 'browser', null,
+                '<span data-filetypesbrowser="'.$this->getAttribute('id').'"></span>'),
+
+            $this->createFormElement('static', 'descriptions'),
+        ]);
+    }
+
+    /**
+     * Return the selected file types.
+     *
+     * @param array $submitValues submitted values
+     * @param bool $assoc if true the retured value is associated array
+     * @return array
+     */
+    public function exportValue(&$submitValues, $assoc = false) {
+
+        $value = '';
+        $filetypeselement = null;
+
+        foreach ($this->_elements as $key => $element) {
+            if ($element->_attributes['name'] === 'filetypes') {
+                $filetypeselement = $this->_elements[$key];
+            }
+        }
+
+        if ($filetypeselement) {
+            $formval = $filetypeselement->exportValue($submitValues[$this->getName()], false);
+            if ($formval) {
+                $value = $this->util->normalize_file_types($formval);
+                if ($value === ['*'] && !$this->allowall) {
+                    $value = [];
+                }
+                $value = implode(',', $value);
+            }
+        }
+
+        return $this->_prepareValue($value, $assoc);
+    }
+
+    /**
+     * Accepts a renderer (called shortly before the renderer's toHtml() method).
+     *
+     * @param HTML_QuickForm_Renderer $renderer An HTML_QuickForm_Renderer object
+     * @param bool $required Whether a group is required
+     * @param string $error An error message associated with a group
+     */
+    public function accept(&$renderer, $required = false, $error = null) {
+        global $PAGE;
+
+        if ($this->isFrozen()) {
+            // Don't render the choose button if the control is frozen.
+            foreach ($this->_elements as $key => $element) {
+                if ($element->_attributes['name'] === 'browser') {
+                    unset($this->_elements[$key]);
+                }
+            }
+        }
+
+        parent::accept($renderer, $required, $error);
+    }
+
+    /**
+     * Called by HTML_QuickForm whenever form event is made on this element
+     *
+     * @param string $event Name of event
+     * @param mixed $arg event arguments
+     * @param object $caller calling object
+     * @return bool
+     */
+    public function onQuickFormEvent($event, $arg, &$caller) {
+        global $OUTPUT;
+
+        switch ($event) {
+            case 'updateValue':
+                $value = $this->_findValue($caller->_constantValues);
+                if (null === $value) {
+                    if ($caller->isSubmitted()) {
+                        $value = $this->_findValue($caller->_submitValues);
+                    } else {
+                        $value = (string)$this->_findValue($caller->_defaultValues);
+                    }
+                }
+                if (!is_array($value)) {
+                    $value = array('filetypes' => $value);
+                }
+                if ($value['filetypes'] !== null) {
+                    $filetypes = $this->util->normalize_file_types($value['filetypes']);
+					if ($filetypes === ['*'] && !$this->allowall) {
+						$filetypes = [];
+					}
+                    $value['descriptions'] = $OUTPUT->render_from_template('core_form/filetypes-descriptions',
+                        $this->util->describe_file_types($filetypes));
+                }
+                $this->setValue($value);
+                return true;
+        }
+
+        return parent::onQuickFormEvent($event, $arg, $caller);
+    }
+}
diff --git a/lib/form/templates/filetypes-descriptions.mustache b/lib/form/templates/filetypes-descriptions.mustache
new file mode 100644
index 00000000000..affe8e6a046
--- /dev/null
+++ b/lib/form/templates/filetypes-descriptions.mustache
@@ -0,0 +1,60 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_form/filetypes-descriptions
+
+    Template to describe chosen file types.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * hasdescriptions (bool)
+    * descriptions (array)
+        * description (string)
+        * extensions (string)
+
+    Example context (json):
+    {
+        "hasdescriptions": true,
+        "descriptions": [
+            {
+                "description": "Image (JPEG)",
+                "extensions": ".jpeg .jpe .jpg"
+            },
+            {
+                "description": "Image (GIF)",
+                "extensions": ".gif"
+            }
+        ]
+    }
+}}
+<div class="form-filetypes-descriptions">
+{{#hasdescriptions}}
+    <ul class="list-unstyled unstyled">
+        {{#descriptions}}
+        <li>{{description}} <small class="text-muted muted">{{extensions}}</small></li>
+        {{/descriptions}}
+    </ul>
+{{/hasdescriptions}}
+{{^hasdescriptions}}
+    <p>{{#str}}noselection, form{{/str}}</p>
+{{/hasdescriptions}}
+</div>
diff --git a/lib/formslib.php b/lib/formslib.php
index b3409d02fdc..5556b1e4097 100644
--- a/lib/formslib.php
+++ b/lib/formslib.php
@@ -3035,6 +3035,7 @@ MoodleQuickForm::registerElementType('duration', "$CFG->libdir/form/duration.php
 MoodleQuickForm::registerElementType('editor', "$CFG->libdir/form/editor.php", 'MoodleQuickForm_editor');
 MoodleQuickForm::registerElementType('filemanager', "$CFG->libdir/form/filemanager.php", 'MoodleQuickForm_filemanager');
 MoodleQuickForm::registerElementType('filepicker', "$CFG->libdir/form/filepicker.php", 'MoodleQuickForm_filepicker');
+MoodleQuickForm::registerElementType('filetypes', "$CFG->libdir/form/filetypes.php", 'MoodleQuickForm_filetypes');
 MoodleQuickForm::registerElementType('grading', "$CFG->libdir/form/grading.php", 'MoodleQuickForm_grading');
 MoodleQuickForm::registerElementType('group', "$CFG->libdir/form/group.php", 'MoodleQuickForm_group');
 MoodleQuickForm::registerElementType('header', "$CFG->libdir/form/header.php", 'MoodleQuickForm_header');
-- 
2.17.1

