Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.49% covered (success)
91.49%
43 / 47
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
TempDirManager
91.49% covered (success)
91.49%
43 / 47
81.82% covered (warning)
81.82%
9 / 11
26.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 __destruct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 teardown
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 deleteDirectory
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 preparePath
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 checkTraversal
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
8.81
 prepareBasePath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setupBase
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setupSubdirectory
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Shellbox;
4
5use DirectoryIterator;
6use Psr\Log\LoggerInterface;
7use Psr\Log\NullLogger;
8
9/**
10 * Manager for a temporary directory which is lazily created, with lazily
11 * created subdirectories underneath, and some path traversal protection to
12 * make sure files stay inside the directory. All files within the directory
13 * are deleted when teardown() is called or when the object is destroyed.
14 */
15class TempDirManager {
16    /** @var string */
17    private $path;
18    /** @var bool */
19    private $baseSetupDone = false;
20    /** @var bool[] */
21    private $subdirSetupDone = [];
22    /** @var LoggerInterface */
23    private $logger;
24
25    /**
26     * @param string $path
27     */
28    public function __construct( $path ) {
29        $this->path = $path;
30        $this->logger = new NullLogger;
31    }
32
33    public function __destruct() {
34        $this->teardown();
35    }
36
37    /**
38     * Set the logger.
39     *
40     * @param LoggerInterface $logger
41     */
42    public function setLogger( LoggerInterface $logger ) {
43        $this->logger = $logger;
44    }
45
46    /**
47     * Destroy the base directory and all files within it
48     */
49    public function teardown() {
50        if ( $this->baseSetupDone ) {
51            $this->deleteDirectory( $this->path );
52            $this->baseSetupDone = false;
53            $this->subdirSetupDone = [];
54        }
55    }
56
57    /**
58     * Recursively delete a specified directory. Note that this may fail in
59     * adversarial situations. For example, a subdirectory with mode 000 cannot
60     * be read and so files within it cannot be unlinked.
61     *
62     * @param string $path
63     */
64    private function deleteDirectory( $path ) {
65        foreach ( new DirectoryIterator( $path ) as $fileInfo ) {
66            if ( $fileInfo->isDot() ) {
67                continue;
68            }
69            if ( $fileInfo->isDir() ) {
70                $this->deleteDirectory( "$path/$fileInfo" );
71            } else {
72                // phpcs:ignore Generic.PHP.NoSilencedErrors
73                if ( !@unlink( "$path/$fileInfo" ) ) {
74                    $this->logger->warning( "Unable to remove file \"$path/$fileInfo\"" );
75                } else {
76                    $this->logger->debug( "Removed file \"$path/$fileInfo\"" );
77                }
78            }
79        }
80        // phpcs:ignore Generic.PHP.NoSilencedErrors
81        if ( !@rmdir( $path ) ) {
82            $this->logger->warning( "Unable to remove directory \"$path\"" );
83        } else {
84            $this->logger->debug( "Removed directory \"$path\"" );
85        }
86    }
87
88    /**
89     * Create directories necessary to make sure a relative path exists,
90     * and return the absolute path.
91     *
92     * @param string $name
93     * @return string
94     * @throws ShellboxError
95     */
96    public function preparePath( $name ) {
97        $this->checkTraversal( $name );
98        $this->setupBase();
99        $dir = '';
100        $components = explode( '/', $name );
101        for ( $i = 0; $i < count( $components ) - 1; $i++ ) {
102            $component = $components[$i];
103            $dir .= $component;
104            $this->setupSubdirectory( $dir );
105        }
106        return "{$this->path}/$name";
107    }
108
109    /**
110     * Make sure the specified filename is acceptable. Throw an exception if it
111     * is not.
112     *
113     * @param string $name
114     * @throws ShellboxError
115     */
116    private function checkTraversal( $name ) {
117        // Backslashes should have been normalized to slashes
118        if ( strlen( $name ) === 0
119            || strcspn( $name, "\0\\" ) !== strlen( $name )
120        ) {
121            throw new ShellboxError( "Invalid file name: \"$name\"" );
122        }
123
124        foreach ( explode( '/', $name ) as $component ) {
125            if ( $component === '' || $component === '.' || $component === '..' ) {
126                throw new ShellboxError( "Invalid path traversal: \"$name\"" );
127            }
128        }
129    }
130
131    /**
132     * Get the base path. Create it if it doesn't exist.
133     *
134     * @return string
135     */
136    public function prepareBasePath() {
137        $this->setupBase();
138        return $this->path;
139    }
140
141    /**
142     * Convert a relative path to an absolute path, but don't create any
143     * directories. This can be used before attempting to read a file.
144     *
145     * @param string $name
146     * @return string
147     */
148    public function getPath( $name ) {
149        $this->checkTraversal( $name );
150        return "{$this->path}/$name";
151    }
152
153    /**
154     * Create the base directory if we haven't done that already.
155     * Note that this will throw if the directory already exists, to prevent
156     * another process from attacking Shellbox by creating the subdirectory in
157     * advance.
158     *
159     * @throws ShellboxError
160     */
161    private function setupBase() {
162        if ( !$this->baseSetupDone ) {
163            $this->logger->debug( "Creating base path {$this->path}" );
164            FileUtils::mkdir( $this->path );
165            $this->baseSetupDone = true;
166        }
167    }
168
169    /**
170     * Create a subdirectory if it hasn't already been created.
171     *
172     * @param string $subdir The relative path
173     * @throws ShellboxError
174     */
175    private function setupSubdirectory( $subdir ) {
176        if ( !isset( $this->subdirSetupDone[$subdir] ) ) {
177            $this->setupBase();
178            $this->logger->debug( "Creating subdirectory $subdir" );
179            FileUtils::mkdir( "{$this->path}/$subdir" );
180            $this->subdirSetupDone[$subdir] = true;
181        }
182    }
183}