FileUtils.java

1
package com.renomad.minum.utils;
2
3
import com.renomad.minum.state.Constants;
4
import com.renomad.minum.logging.ILogger;
5
6
import java.io.IOException;
7
import java.nio.charset.StandardCharsets;
8
import java.nio.file.Files;
9
import java.nio.file.LinkOption;
10
import java.nio.file.Path;
11
import java.util.*;
12
import java.util.regex.Pattern;
13
import java.util.stream.Stream;
14
15
/**
16
 * Helper functions for working with files.
17
 */
18
public final class FileUtils {
19
20
    /**
21
     * These patterns can be used in path strings to access files higher in
22
     * the directory structure.  We disallow this, as a security precaution.
23
     * <ul>
24
     * <li>1st Alternative {@code //} - This prevents going to the root directory
25
     * </li>
26
     * <li>2nd Alternative {@code ..} - prevents going up a directory
27
     * </li>
28
     * <li>3rd Alternative {@code :} - prevents certain special paths, like "C:" or "file://"
29
     * </li>
30
     * <li>4th Alternative {@code ^/} - prevents starting with a slash, meaning the root, but
31
     *     allows intermediate slashes.
32
     * </li>
33
     * <li>5th Alternative {@code :^\} - prevents starting with a backslash, meaning the root, but
34
     *    allows intermediate backslashes.
35
     * </li>
36
     * </ul>
37
     */
38
    public static final Pattern badFilePathPatterns = Pattern.compile("//|\\.\\.|:|^/|^\\\\");
39
40
    private final ILogger logger;
41
    private final IFileReader fileReader;
42
43
    public FileUtils(ILogger logger, Constants constants) {
44
        this(
45
                logger,
46
                new FileReader(
47
                        LRUCache.getLruCache(constants.maxElementsLruCacheStaticFiles),
48
                        constants.useCacheForStaticFiles,
49
                        logger));
50
    }
51
52
    /**
53
     * This version of the constructor is mainly for testing
54
     */
55
    FileUtils(ILogger logger, IFileReader fileReader) {
56
        this.logger = logger;
57
        this.fileReader = fileReader;
58
    }
59
60
    /**
61
     * Write a string to a path on disk.
62
     * <br>
63
     * <p>
64
     *  <em>Note: This does *not* protect against untrusted data on its own.  Call {@link #safeResolve(String, String)} first against
65
     *  the path to ensure it uses valid characters and prevent it escaping the expected directory.</em>
66
     * </p>
67
     */
68
    public void writeString(Path path, String content) {
69 1 1. writeString : negated conditional → KILLED
        if (path.toString().isEmpty()) {
70
            logger.logDebug(() -> "an empty path was provided to writeString");
71
            return;
72
        }
73
        try {
74
            Files.writeString(path, content);
75
        } catch (IOException e) {
76
            throw new UtilsException(e);
77
        }
78
    }
79
80
    /**
81
     * Deletes a directory, deleting everything inside it
82
     * recursively afterwards.  A more dangerous method than
83
     * many others, take care.
84
     * <br>
85
     * <p>
86
     *  <em>Note: This does *not* protect against untrusted data on its own.  Call {@link #safeResolve(String, String)} first against
87
     *  the path to ensure it uses valid characters and prevent it escaping the expected directory.</em>
88
     * </p>
89
     */
90
    public void deleteDirectoryRecursivelyIfExists(Path myPath) {
91 1 1. deleteDirectoryRecursivelyIfExists : negated conditional → KILLED
        if (!Files.exists(myPath)) {
92
            logger.logDebug(() -> "system was requested to delete directory: "+myPath+", but it did not exist");
93
        } else {
94 1 1. deleteDirectoryRecursivelyIfExists : removed call to com/renomad/minum/utils/FileUtils::walkPathDeleting → KILLED
            walkPathDeleting(myPath);
95
        }
96
    }
97
98
    void walkPathDeleting(Path myPath) {
99
        try (Stream<Path> walk = Files.walk(myPath)) {
100
101
            final var files = walk.sorted(Comparator.reverseOrder())
102
                    .map(Path::toFile).toList();
103
104
            for(var file: files) {
105
                logger.logDebug(() -> "deleting " + file);
106 1 1. walkPathDeleting : removed call to java/nio/file/Files::delete → KILLED
                Files.delete(file.toPath());
107
            }
108
        } catch (IOException ex) {
109
            throw new UtilsException("Error during deleteDirectoryRecursivelyIfExists: " + ex);
110
        }
111
    }
112
113
    /**
114
     * Creates a directory if it doesn't already exist.
115
     * <br>
116
     * <p>
117
     *  <em>Note: This does *not* protect against untrusted data on its own.  Call {@link #safeResolve(String, String)} first against
118
     *  the path to ensure it uses valid characters and prevent it escaping the expected directory.</em>
119
     * </p>
120
     * <p>
121
     * If the directory does exist, the program will simply skip
122
     * building it, and mention it in the logs.
123
     * </p>
124
     */
125
    public void makeDirectory(Path directory) {
126
        logger.logDebug(() -> "Creating a directory " + directory);
127
        boolean directoryExists = Files.exists(directory);
128
        logger.logDebug(() -> "Directory: " + directory + ". Already exists: " + directory);
129 1 1. makeDirectory : negated conditional → KILLED
        if (!directoryExists) {
130
            logger.logDebug(() -> "Creating directory, since it does not already exist: " + directory);
131 1 1. makeDirectory : removed call to com/renomad/minum/utils/FileUtils::innerCreateDirectory → KILLED
            innerCreateDirectory(directory);
132
            logger.logDebug(() -> "Directory: " + directory + " created");
133
        }
134
    }
135
136
    static void innerCreateDirectory(Path directory) {
137
        try {
138
            Files.createDirectories(directory);
139
        } catch (Exception e) {
140
            throw new UtilsException(e);
141
        }
142
    }
143
144
    /**
145
     * Read a binary file, return as a byte array
146
     * <br>
147
     * <p>
148
     *  <em>Note: This does *not* protect against untrusted data on its own.  Call {@link #safeResolve(String, String)} first against
149
     *  the path to ensure it uses valid characters and prevent it escaping the expected directory.</em>
150
     * </p>
151
     * <p>
152
     *     If there is an error, this will return an empty byte array.
153
     * </p>
154
     */
155
    public byte[] readBinaryFile(String path) {
156
        try {
157 1 1. readBinaryFile : replaced return value with null for com/renomad/minum/utils/FileUtils::readBinaryFile → KILLED
            return fileReader.readFile(path);
158
        } catch (IOException e) {
159
            logger.logDebug(() -> String.format("Error while reading file %s, returning empty byte array. %s", path, e));
160 1 1. readBinaryFile : replaced return value with null for com/renomad/minum/utils/FileUtils::readBinaryFile → KILLED
            return new byte[0];
161
        }
162
    }
163
164
    /**
165
     * Read a text file from the given path, return as a string.
166
     * <br>
167
     * <p>
168
     *  <em>Note: This does *not* protect against untrusted data on its own.  Call {@link #safeResolve(String, String)} first against
169
     *  the path to ensure it uses valid characters and prevent it escaping the expected directory.</em>
170
     * </p>
171
     * <p>
172
     *     If there is an error, this will return an empty string.
173
     * </p>
174
     */
175
    public String readTextFile(String path) {
176
        try {
177 1 1. readTextFile : replaced return value with "" for com/renomad/minum/utils/FileUtils::readTextFile → KILLED
            return new String(fileReader.readFile(path), StandardCharsets.UTF_8);
178
        } catch (IOException e) {
179
            logger.logDebug(() -> String.format("Error while reading file %s, returning empty string. %s", path, e));
180
            return "";
181
        }
182
    }
183
184
    /**
185
     * This method is to provide assurance that the file specified by the path
186
     * parameter is within the directory specified by directoryPath.  Use this
187
     * for any code that reads from files where the user provides untrusted input.
188
     * @throws InvariantException if the file is not within the directory
189
     */
190
    public static void checkFileIsWithinDirectory(String path, String directoryPath) {
191
        Path directoryRealPath;
192
        Path fullRealPath;
193
        try {
194
            directoryRealPath = Path.of(directoryPath).toRealPath(LinkOption.NOFOLLOW_LINKS);
195
            fullRealPath = directoryRealPath.resolve(path).toRealPath(LinkOption.NOFOLLOW_LINKS);
196
        } catch (IOException ex) {
197
            throw new InvariantException(ex.toString());
198
        }
199 1 1. checkFileIsWithinDirectory : negated conditional → KILLED
        if (! fullRealPath.startsWith(directoryRealPath)) {
200
            throw new InvariantException(String.format("path (%s) was not within directory (%s)", path, directoryPath));
201
        }
202
    }
203
204
    /**
205
     * Checks that the path string avoids bad patterns and meets our
206
     * whitelist for acceptable characters.
207
     * @throws InvariantException if there are any issues with the path string, such
208
     *                            as being an empty string, containing known bad patterns from {@link #badFilePathPatterns},
209
     *                            or including characters other than the set of characters we will allow for filenames.
210
     *                            It is a simple set of ascii characters - alphanumerics, underscore, dash, period,
211
     *                            forward and backward slash.
212
     */
213
    public static void checkForBadFilePatterns(String path) {
214 1 1. checkForBadFilePatterns : negated conditional → KILLED
        if (path.isBlank()) {
215
            throw new InvariantException("filename was empty");
216
        }
217 1 1. checkForBadFilePatterns : negated conditional → KILLED
        if (badFilePathPatterns.matcher(path).find()) {
218
            throw new InvariantException("filename ("+path+") contained invalid characters");
219
        }
220
        for (char c : path.toCharArray()) {
221 17 1. checkForBadFilePatterns : changed conditional boundary → SURVIVED
2. checkForBadFilePatterns : changed conditional boundary → KILLED
3. checkForBadFilePatterns : negated conditional → KILLED
4. checkForBadFilePatterns : changed conditional boundary → KILLED
5. checkForBadFilePatterns : negated conditional → KILLED
6. checkForBadFilePatterns : changed conditional boundary → KILLED
7. checkForBadFilePatterns : negated conditional → KILLED
8. checkForBadFilePatterns : negated conditional → KILLED
9. checkForBadFilePatterns : changed conditional boundary → KILLED
10. checkForBadFilePatterns : negated conditional → KILLED
11. checkForBadFilePatterns : negated conditional → KILLED
12. checkForBadFilePatterns : negated conditional → KILLED
13. checkForBadFilePatterns : changed conditional boundary → KILLED
14. checkForBadFilePatterns : negated conditional → KILLED
15. checkForBadFilePatterns : negated conditional → KILLED
16. checkForBadFilePatterns : negated conditional → KILLED
17. checkForBadFilePatterns : negated conditional → KILLED
            boolean isWhitelistedChar = c >= 'A' && c <= 'Z' || c >= 'a' && c<= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' || c == '\\' || c == '/';
222 1 1. checkForBadFilePatterns : negated conditional → KILLED
            if (! isWhitelistedChar) {
223
                throw new InvariantException("filename ("+path+") contained invalid characters ("+c+").  Allowable characters are alpha-numeric ascii both cases, underscore, forward and backward-slash, period, and dash");
224
            }
225
        }
226
    }
227
228
    /**
229
     * This helper method will ensure that the requested path is
230
     * within the parent directory and using safe characters
231
     */
232
    public static Path safeResolve(String parentDirectory, String path) {
233 1 1. safeResolve : removed call to com/renomad/minum/utils/FileUtils::checkForBadFilePatterns → KILLED
        checkForBadFilePatterns(path);
234 1 1. safeResolve : removed call to com/renomad/minum/utils/FileUtils::checkFileIsWithinDirectory → SURVIVED
        checkFileIsWithinDirectory(path, parentDirectory);
235 1 1. safeResolve : replaced return value with null for com/renomad/minum/utils/FileUtils::safeResolve → KILLED
        return Path.of(parentDirectory).resolve(path);
236
    }
237
238
}

Mutations

69

1.1
Location : writeString
Killed by : com.renomad.minum.web.FullSystemTests.testFullSystem_EdgeCase_InstantlyClosed(com.renomad.minum.web.FullSystemTests)
negated conditional → KILLED

91

1.1
Location : deleteDirectoryRecursivelyIfExists
Killed by : com.renomad.minum.database.DbTests.testWalkAndLoad(com.renomad.minum.database.DbTests)
negated conditional → KILLED

94

1.1
Location : deleteDirectoryRecursivelyIfExists
Killed by : com.renomad.minum.database.DbTests.test_Deserialization_EdgeCases(com.renomad.minum.database.DbTests)
removed call to com/renomad/minum/utils/FileUtils::walkPathDeleting → KILLED

106

1.1
Location : walkPathDeleting
Killed by : com.renomad.minum.database.DbTests.test_Deserialization_EdgeCases(com.renomad.minum.database.DbTests)
removed call to java/nio/file/Files::delete → KILLED

129

1.1
Location : makeDirectory
Killed by : com.renomad.minum.web.FullSystemTests.testFullSystem_EdgeCase_InstantlyClosed(com.renomad.minum.web.FullSystemTests)
negated conditional → KILLED

131

1.1
Location : makeDirectory
Killed by : com.renomad.minum.database.DbTests.testDeserializerComplaints(com.renomad.minum.database.DbTests)
removed call to com/renomad/minum/utils/FileUtils::innerCreateDirectory → KILLED

157

1.1
Location : readBinaryFile
Killed by : com.renomad.minum.utils.FileUtilsTests
replaced return value with null for com/renomad/minum/utils/FileUtils::readBinaryFile → KILLED

160

1.1
Location : readBinaryFile
Killed by : com.renomad.minum.utils.FileUtilsTests
replaced return value with null for com/renomad/minum/utils/FileUtils::readBinaryFile → KILLED

177

1.1
Location : readTextFile
Killed by : com.renomad.minum.templating.TemplatingTests
replaced return value with "" for com/renomad/minum/utils/FileUtils::readTextFile → KILLED

199

1.1
Location : checkFileIsWithinDirectory
Killed by : com.renomad.minum.web.ResponseTests.testResponse_EdgeCase_BadPathRequested(com.renomad.minum.web.ResponseTests)
negated conditional → KILLED

214

1.1
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.ResponseTests.testResponse_EdgeCase_BadPathRequested(com.renomad.minum.web.ResponseTests)
negated conditional → KILLED

217

1.1
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.ResponseTests.testResponse_EdgeCase_BadPathRequested(com.renomad.minum.web.ResponseTests)
negated conditional → KILLED

221

1.1
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.FunctionalTests
changed conditional boundary → KILLED

2.2
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.utils.FileUtilsTests
negated conditional → KILLED

3.3
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.WebFrameworkTests.test_readStaticFile_JS(com.renomad.minum.web.WebFrameworkTests)
changed conditional boundary → KILLED

4.4
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.utils.FileUtilsTests
negated conditional → KILLED

5.5
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.utils.FileUtilsTests
changed conditional boundary → KILLED

6.6
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.ResponseTests.testResponse_EdgeCase_BadPathRequested(com.renomad.minum.web.ResponseTests)
negated conditional → KILLED

7.7
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.templating.TemplatingTests
negated conditional → KILLED

8.8
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.FunctionalTests
changed conditional boundary → KILLED

9.9
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.WebFrameworkTests.test_readStaticFile_JS(com.renomad.minum.web.WebFrameworkTests)
negated conditional → KILLED

10.10
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.WebFrameworkTests.test_Edge_ApplicationOctetStream(com.renomad.minum.web.WebFrameworkTests)
negated conditional → KILLED

11.11
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.utils.FileUtilsTests
negated conditional → KILLED

12.12
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.htmlparsing.HtmlParserTests
changed conditional boundary → KILLED

13.13
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.ResponseTests.testResponse_EdgeCase_BadPathRequested(com.renomad.minum.web.ResponseTests)
negated conditional → KILLED

14.14
Location : checkForBadFilePatterns
Killed by : none
changed conditional boundary → SURVIVED
Covering tests

15.15
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.WebFrameworkTests.test_Edge_ApplicationOctetStream(com.renomad.minum.web.WebFrameworkTests)
negated conditional → KILLED

16.16
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.ResponseTests.testResponse_EdgeCase_BadPathRequested(com.renomad.minum.web.ResponseTests)
negated conditional → KILLED

17.17
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.ResponseTests.testResponse_EdgeCase_BadPathRequested(com.renomad.minum.web.ResponseTests)
negated conditional → KILLED

222

1.1
Location : checkForBadFilePatterns
Killed by : com.renomad.minum.web.ResponseTests.testResponse_EdgeCase_BadPathRequested(com.renomad.minum.web.ResponseTests)
negated conditional → KILLED

233

1.1
Location : safeResolve
Killed by : com.renomad.minum.web.ResponseTests.testResponse_EdgeCase_BadPathRequested(com.renomad.minum.web.ResponseTests)
removed call to com/renomad/minum/utils/FileUtils::checkForBadFilePatterns → KILLED

234

1.1
Location : safeResolve
Killed by : none
removed call to com/renomad/minum/utils/FileUtils::checkFileIsWithinDirectory → SURVIVED
Covering tests

235

1.1
Location : safeResolve
Killed by : com.renomad.minum.web.ResponseTests.testResponse_EdgeCase_BadPathRequested(com.renomad.minum.web.ResponseTests)
replaced return value with null for com/renomad/minum/utils/FileUtils::safeResolve → KILLED

Active mutators

Tests examined


Report generated by PIT 1.17.0