Lines
48.59%
52 / 107
Methods
33.33%
4 / 12
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| rootPath | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| overrideRootPath | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | ||
| isAbsolute | 83.33% | 5 / 6 | 0.00% | 0 / 1 | 4.07 | ||
| resolvePath | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| absolutePath | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| normalizePath | 100.00% | 25 / 25 | 100.00% | 1 / 1 | 13 | ||
| isWithinRoot | 89.47% | 17 / 19 | 0.00% | 0 / 1 | 6.04 | ||
| getFiles | 0.00% | 0 / 22 | 0.00% | 0 / 1 | 90 | ||
| get | 0.00% | 0 / 9 | 0.00% | 0 / 1 | 20 | ||
| create | 0.00% | 0 / 13 | 0.00% | 0 / 1 | 42 | ||
| formatFileSize | 0.00% | 0 / 6 | 0.00% | 0 / 1 | 6 | ||
| root | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | ||
| 20 | class FileSystem | |
| 21 | { | |
| 22 | /** | |
| 23 | * The root path used for resolving relative paths | |
| 24 | * | |
| 25 | * @var string | |
| 26 | */ | |
| 27 | private static string $root_path; | |
| 28 | ||
| 29 | /** | |
| 30 | * Get the current root path. The trailing `/` will be removed before this function is called. | |
| 31 | * | |
| 32 | * @return string The current root path | |
| 33 | */ | |
| 34 | public static function rootPath(): string | |
| 35 | { | |
| 36 | return self::$root_path; | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Override the default root path | |
| 41 | * | |
| 42 | * @param string $path The new root path | |
| 43 | * @return void | |
| 44 | */ | |
| 45 | public static function overrideRootPath(string $path): void | |
| 46 | { | |
| 47 | self::$root_path = rtrim($path, DIRECTORY_SEPARATOR); | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Determine whether a path is absolute. | |
| 52 | * | |
| 53 | * Recognises Unix-style paths (leading "/") and Windows-style paths | |
| 54 | * (leading drive letter such as "C:\" or "C:/"). | |
| 55 | * | |
| 56 | * @param string $path The path to test | |
| 57 | * @return bool True if the path is absolute, false otherwise | |
| 58 | */ | |
| 59 | public static function isAbsolute(string $path): bool | |
| 60 | { | |
| 61 | if ($path === '') { | |
| 62 | return false; | |
| 63 | } | |
| 64 | ||
| 65 | // Unix-style absolute path. | |
| 66 | if (str_starts_with($path, '/')) { | |
| 67 | return true; | |
| 68 | } | |
| 69 | ||
| 70 | // Windows-style absolute path (e.g. C:\ or C:/). | |
| 71 | return DIRECTORY_SEPARATOR === '\\' | |
| 72 | && preg_match('/^[A-Z]:[\\\\\/]/i', $path) === 1; | |
| 73 | } | |
| 74 | ||
| 75 | /** | |
| 76 | * Resolve a path against the configured root. | |
| 77 | * | |
| 78 | * The path is always treated as relative to the root: a leading | |
| 79 | * separator is stripped and the root path is prepended. This matches | |
| 80 | * the convention used by {@see File} and {@see Folder}, where a leading | |
| 81 | * `/` means "relative to root" rather than an absolute filesystem path. | |
| 82 | * The result is normalized but not checked against the containment | |
| 83 | * guard — callers that construct {@see File} or {@see Folder} objects | |
| 84 | * get that check from the constructor. | |
| 85 | * | |
| 86 | * @param string $path The path to resolve (relative to root) | |
| 87 | * @return string The resolved absolute path | |
| 88 | */ | |
| 89 | public static function resolvePath(string $path): string | |
| 90 | { | |
| 91 | return self::rootPath() . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR); | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Resolve a path to an absolute filesystem path. | |
| 96 | * | |
| 97 | * Absolute paths are returned unchanged; relative paths are resolved | |
| 98 | * against the root via {@see resolvePath()}. Unlike {@see resolvePath()}, | |
| 99 | * a leading separator is treated as a filesystem-absolute path rather | |
| 100 | * than root-relative. | |
| 101 | * | |
| 102 | * @param string $path The path to resolve (relative or absolute) | |
| 103 | * @return string The resolved absolute path | |
| 104 | */ | |
| 105 | public static function absolutePath(string $path): string | |
| 106 | { | |
| 107 | return self::isAbsolute($path) | |
| 108 | ? $path | |
| 109 | : self::resolvePath($path); | |
| 110 | } | |
| 111 | ||
| 112 | /** | |
| 113 | * Normalize a path lexically, resolving `.` and `..` segments. | |
| 114 | * | |
| 115 | * This is a pure string operation — it does not touch the filesystem, so | |
| 116 | * it works for paths that do not exist yet. It preserves the leading | |
| 117 | * separator for absolute paths, Windows drive-letter prefixes, and keeps | |
| 118 | * leading `..` segments for relative paths. | |
| 119 | * | |
| 120 | * @param string $path The path to normalize | |
| 121 | * @return string The normalized path | |
| 122 | */ | |
| 123 | public static function normalizePath(string $path): string | |
| 124 | { | |
| 125 | if ($path === '') { | |
| 126 | return ''; | |
| 127 | } | |
| 128 | ||
| 129 | // Preserve a Windows drive-letter prefix (e.g. "C:"). | |
| 130 | $prefix = ''; | |
| 131 | if (preg_match('/^[A-Za-z]:/', $path)) { | |
| 132 | $prefix = substr($path, 0, 2); | |
| 133 | $path = substr($path, 2); | |
| 134 | } | |
| 135 | ||
| 136 | // Preserve whether the path is absolute. | |
| 137 | $isAbsolute = str_starts_with($path, '/') || str_starts_with($path, '\\'); | |
| 138 | if ($isAbsolute) { | |
| 139 | $path = ltrim($path, '/\\'); | |
| 140 | } | |
| 141 | ||
| 142 | $segments = preg_split('/[\/\\\\]+/', $path); | |
| 143 | $stack = []; | |
| 144 | ||
| 145 | foreach ($segments as $segment) { | |
| 146 | if ($segment === '' || $segment === '.') { | |
| 147 | continue; | |
| 148 | } | |
| 149 | ||
| 150 | if ($segment === '..') { | |
| 151 | if (!empty($stack) && end($stack) !== '..') { | |
| 152 | array_pop($stack); | |
| 153 | } elseif (!$isAbsolute) { | |
| 154 | // Preserve leading ".." for relative paths. | |
| 155 | $stack[] = '..'; | |
| 156 | } | |
| 157 | continue; | |
| 158 | } | |
| 159 | ||
| 160 | $stack[] = $segment; | |
| 161 | } | |
| 162 | ||
| 163 | $result = implode(DIRECTORY_SEPARATOR, $stack); | |
| 164 | ||
| 165 | if ($isAbsolute) { | |
| 166 | $result = DIRECTORY_SEPARATOR . $result; | |
| 167 | } | |
| 168 | ||
| 169 | return $prefix . $result; | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * Determine whether a path is contained within the configured root path. | |
| 174 | * | |
| 175 | * Resolves the path (handling `..` segments and symlinks) and checks it | |
| 176 | * does not escape the root. Works for both existing and non-existent | |
| 177 | * paths — the latter are resolved via their parent directory. | |
| 178 | * | |
| 179 | * @param string $path The absolute path to check | |
| 180 | * @return bool True if the path is within the root, false otherwise | |
| 181 | */ | |
| 182 | public static function isWithinRoot(string $path): bool | |
| 183 | { | |
| 184 | $path = self::normalizePath($path); | |
| 185 | ||
| 186 | $root = realpath(self::$root_path); | |
| 187 | if ($root === false) { | |
| 188 | // Root doesn't exist yet; fall back to a lexical comparison. | |
| 189 | $root = rtrim(self::$root_path, DIRECTORY_SEPARATOR); | |
| 190 | } | |
| 191 | $root = rtrim($root, DIRECTORY_SEPARATOR); | |
| 192 | ||
| 193 | // Resolve the deepest existing ancestor of $path, then re-append the | |
| 194 | // remaining (possibly non-existent) segments lexically. This handles | |
| 195 | // paths whose intermediate directories don't exist yet (e.g. a File | |
| 196 | // created before its parent Folder). | |
| 197 | $resolved = $path; | |
| 198 | $suffix = ''; | |
| 199 | while (true) { | |
| 200 | $real = realpath($resolved); | |
| 201 | if ($real !== false) { | |
| 202 | $resolved = $real . $suffix; | |
| 203 | break; | |
| 204 | } | |
| 205 | $parent = dirname($resolved); | |
| 206 | if ($parent === $resolved) { | |
| 207 | // Reached the filesystem root without finding an existing ancestor. | |
| 208 | return false; | |
| 209 | } | |
| 210 | $suffix = DIRECTORY_SEPARATOR . basename($resolved) . $suffix; | |
| 211 | $resolved = $parent; | |
| 212 | } | |
| 213 | ||
| 214 | return $resolved === $root | |
| 215 | || str_starts_with($resolved, $root . DIRECTORY_SEPARATOR); | |
| 216 | } | |
| 217 | ||
| 218 | /** | |
| 219 | * Get all files in a directory recursively with optional extension filtering | |
| 220 | * | |
| 221 | * @param string|null $directory The directory to scan (relative to root path), or null for root path | |
| 222 | * @param string|array|null $extensions Optional extensions to filter by (e.g., 'php' or ['php', 'js']) | |
| 223 | * @param bool $recursive Whether to search recursively in subdirectories | |
| 224 | * @return array Array of File objects representing files in the directory | |
| 225 | * @throws Exception | |
| 226 | */ | |
| 227 | public static function getFiles(?string $directory = null, string|array|null $extensions = null, bool $recursive = true): array | |
| 228 | { | |
| 229 | // Determine the directory path | |
| 230 | if ($directory == null) { | |
| 231 | $directoryPath = self::rootPath(); | |
| 232 | } else { | |
| 233 | $cleanDir = ltrim($directory, DIRECTORY_SEPARATOR); | |
| 234 | $directoryPath = self::normalizePath(self::$root_path . DIRECTORY_SEPARATOR . $cleanDir); | |
| 235 | } | |
| 236 | ||
| 237 | // Normalize extensions to array and lowercase if provided | |
| 238 | if ($extensions !== null) { | |
| 239 | $extensions = is_array($extensions) ? $extensions : [$extensions]; | |
| 240 | $extensions = array_map('strtolower', $extensions); | |
| 241 | } | |
| 242 | ||
| 243 | $items = []; | |
| 244 | ||
| 245 | // Set up the appropriate iterator based on a recursive flag | |
| 246 | if ($recursive) { | |
| 247 | $iterator = new RecursiveIteratorIterator( | |
| 248 | new RecursiveDirectoryIterator($directoryPath, FilesystemIterator::SKIP_DOTS), | |
| 249 | RecursiveIteratorIterator::SELF_FIRST | |
| 250 | ); | |
| 251 | } else { | |
| 252 | $iterator = new \DirectoryIterator($directoryPath); | |
| 253 | } | |
| 254 | ||
| 255 | foreach ($iterator as $fileInfo) { | |
| 256 | if ($fileInfo->isFile()) { | |
| 257 | // If extension filter is provided, check if file matches | |
| 258 | if ($extensions !== null) { | |
| 259 | $extension = strtolower(pathinfo($fileInfo->getFilename(), PATHINFO_EXTENSION)); | |
| 260 | if (!in_array($extension, $extensions)) { | |
| 261 | continue; // Skip files that don't match the extensions | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | // Create a file object with absolute path (true) | |
| 266 | $items[] = new File($fileInfo->getRealPath(), true); | |
| 267 | } | |
| 268 | } | |
| 269 | ||
| 270 | return $items; | |
| 271 | } | |
| 272 | ||
| 273 | /** | |
| 274 | * Get a file instance if it exists, or null if it doesn't | |
| 275 | * | |
| 276 | * @param string $path Path to the file (relative to root path) | |
| 277 | * @return File|null File instance or null if file doesn't exist | |
| 278 | */ | |
| 279 | public static function get(string $path): ?File | |
| 280 | { | |
| 281 | try { | |
| 282 | // Clean the path | |
| 283 | $cleanPath = ltrim($path, DIRECTORY_SEPARATOR); | |
| 284 | $fullPath = self::normalizePath(self::$root_path . DIRECTORY_SEPARATOR . $cleanPath); | |
| 285 | ||
| 286 | // Containment guard: reject paths that escape the root. | |
| 287 | if (!self::isWithinRoot($fullPath)) { | |
| 288 | return null; | |
| 289 | } | |
| 290 | ||
| 291 | // Contract: return null if the file doesn't exist. | |
| 292 | if (!file_exists($fullPath)) { | |
| 293 | return null; | |
| 294 | } | |
| 295 | ||
| 296 | return new File($fullPath, true); | |
| 297 | } catch (Exception $e) { | |
| 298 | return null; | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | /** | |
| 303 | * Create a new file with optional content | |
| 304 | * | |
| 305 | * This method creates the directory structure if it doesn't exist | |
| 306 | * and initializes the file with the provided content if any. | |
| 307 | * | |
| 308 | * @param string $path Path to the file (relative to root path) | |
| 309 | * @param string $content Optional initial content for the file | |
| 310 | * @return File|null The file instance or null on failure | |
| 311 | */ | |
| 312 | public static function create(string $path, string $content = ''): ?File | |
| 313 | { | |
| 314 | // Clean the path | |
| 315 | $cleanPath = ltrim($path, DIRECTORY_SEPARATOR); | |
| 316 | $fullPath = self::normalizePath(self::$root_path . DIRECTORY_SEPARATOR . $cleanPath); | |
| 317 | ||
| 318 | // Containment guard: reject paths that escape the root. | |
| 319 | if (!self::isWithinRoot($fullPath)) { | |
| 320 | return null; | |
| 321 | } | |
| 322 | ||
| 323 | // Create directory if it doesn't exist | |
| 324 | $directory = dirname($fullPath); | |
| 325 | if (!is_dir($directory)) { | |
| 326 | if (!mkdir($directory, 0755, true)) { | |
| 327 | return null; | |
| 328 | } | |
| 329 | } | |
| 330 | ||
| 331 | // Create the file with initial content | |
| 332 | if (file_put_contents($fullPath, $content) === false) { | |
| 333 | return null; | |
| 334 | } | |
| 335 | ||
| 336 | try { | |
| 337 | // Return a new File instance with absolute path | |
| 338 | return new File($fullPath, true); | |
| 339 | } catch (Exception $e) { | |
| 340 | return null; | |
| 341 | } | |
| 342 | } | |
| 343 | ||
| 344 | /** | |
| 345 | * Format file size in a human-readable format | |
| 346 | * | |
| 347 | * @param int $bytes File size in bytes | |
| 348 | * @return string Formatted file size | |
| 349 | */ | |
| 350 | public static function formatFileSize(int $bytes): string | |
| 351 | { | |
| 352 | $units = ['B', 'KB', 'MB', 'GB', 'TB']; | |
| 353 | ||
| 354 | $bytes = max($bytes, 0); | |
| 355 | $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); | |
| 356 | $pow = min($pow, count($units) - 1); | |
| 357 | ||
| 358 | $bytes /= pow(1024, $pow); | |
| 359 | ||
| 360 | return round($bytes, 2) . ' ' . $units[$pow]; | |
| 361 | } | |
| 362 | ||
| 363 | public static function root(): Folder | |
| 364 | { | |
| 365 | return new Folder(self::$root_path, true); | |
| 366 | } | |
| 367 | } |