ByteStream
6 days ago
Layer
6 days ago
Mixin
6 days ago
Path
6 days ago
Visitor
6 days ago
LICENSE.md
6 days ago
README.md
6 days ago
class-filesystemexception.php
6 days ago
class-inmemoryfilesystem.php
6 days ago
class-localfilesystem.php
6 days ago
class-sqlitefilesystem.php
6 days ago
class-uploadedfilesystem.php
6 days ago
composer.json
6 days ago
functions.php
6 days ago
interface-filesystem.php
6 days ago
README.md
262 lines
| 1 | --- |
| 2 | slug: filesystem |
| 3 | title: Filesystem |
| 4 | install: wp-php-toolkit/filesystem |
| 5 | |
| 6 | see_also: |
| 7 | - bytestream | ByteStream | Open files as readers and writers instead of loading full strings. |
| 8 | - zip | Zip | Mount archives and copy data between archive-backed and normal filesystems. |
| 9 | - git | Git | Expose repository trees through a filesystem-shaped API. |
| 10 | --- |
| 11 | |
| 12 | One <code>Filesystem</code> interface across local disk, in-memory trees, SQLite databases, and ZIP archives. Forward-slash paths everywhere — even on Windows — so the same code runs in tests, in production, and inside read-only ZIP-backed trees. |
| 13 | |
| 14 | ## Why this exists |
| 15 | |
| 16 | <p>Code that touches the filesystem is hard to test, hard to port to Windows, and impossible to point at non-disk storage without rewriting it. Swap <code>LocalFilesystem</code> for <code>InMemoryFilesystem</code> in tests and your suite stops touching <code>/tmp</code>; swap it for <code>SQLiteFilesystem</code> when the <code>sqlite3</code> extension is available and your "files" become rows in a portable database; swap it for <code>ZipFilesystem</code> and you can read inside an archive with the same calls.</p> |
| 17 | |
| 18 | <p>Every backend uses forward slashes regardless of host OS. No <code>DIRECTORY_SEPARATOR</code> juggling, no Windows-only test failures, no surprises when a path moves between backends.</p> |
| 19 | |
| 20 | ## In-memory tree |
| 21 | |
| 22 | <p>The fastest backend. No disk I/O, no cleanup, no test-isolation problems.</p> |
| 23 | |
| 24 | <!-- snippet: |
| 25 | filename: teaser-memory.php |
| 26 | runnable: true |
| 27 | --> |
| 28 | ```php |
| 29 | <?php |
| 30 | require '/php-toolkit/vendor/autoload.php'; |
| 31 | |
| 32 | use WordPress\Filesystem\InMemoryFilesystem; |
| 33 | |
| 34 | $fs = InMemoryFilesystem::create(); |
| 35 | $fs->put_contents( '/hello.txt', 'Hello, world!' ); |
| 36 | echo $fs->get_contents( '/hello.txt' ); |
| 37 | ``` |
| 38 | |
| 39 | <!-- expected-output --> |
| 40 | ``` |
| 41 | Hello, world! |
| 42 | ``` |
| 43 | |
| 44 | ## Test code without touching disk |
| 45 | |
| 46 | <p>Code that takes a <code>Filesystem</code> parameter, instead of calling <code>file_get_contents()</code> directly, can be tested against an <code>InMemoryFilesystem</code>. The test sets up files in memory, exercises the function, and asserts on what got written — no temp directories, no cleanup.</p> |
| 47 | |
| 48 | <!-- snippet: |
| 49 | filename: test-without-disk.php |
| 50 | runnable: true |
| 51 | --> |
| 52 | ```php |
| 53 | <?php |
| 54 | require '/php-toolkit/vendor/autoload.php'; |
| 55 | |
| 56 | use WordPress\Filesystem\Filesystem; |
| 57 | use WordPress\Filesystem\InMemoryFilesystem; |
| 58 | |
| 59 | function bump_version( Filesystem $fs, $path ) { |
| 60 | $json = json_decode( $fs->get_contents( $path ), true ); |
| 61 | list( $maj, $min, $patch ) = explode( '.', $json['version'] ); |
| 62 | $json['version'] = $maj . '.' . $min . '.' . ( (int) $patch + 1 ); |
| 63 | $fs->put_contents( $path, json_encode( $json ) ); |
| 64 | } |
| 65 | |
| 66 | $fs = InMemoryFilesystem::create(); |
| 67 | $fs->put_contents( '/package.json', '{"version":"1.2.3"}' ); |
| 68 | bump_version( $fs, '/package.json' ); |
| 69 | |
| 70 | echo $fs->get_contents( '/package.json' ) . "\n"; |
| 71 | ``` |
| 72 | |
| 73 | <!-- expected-output --> |
| 74 | ``` |
| 75 | {"version":"1.2.4"} |
| 76 | ``` |
| 77 | |
| 78 | ## Local disk with a chrooted root |
| 79 | |
| 80 | <p><code>LocalFilesystem::create($root)</code> is implicitly chrooted: every path resolves relative to <code>$root</code> and a <code>../</code> cannot escape. Reach for it when a request path or CLI argument names a file inside one project directory.</p> |
| 81 | |
| 82 | <!-- snippet: |
| 83 | filename: local-chroot.php |
| 84 | runnable: true |
| 85 | --> |
| 86 | ```php |
| 87 | <?php |
| 88 | require '/php-toolkit/vendor/autoload.php'; |
| 89 | |
| 90 | use WordPress\Filesystem\LocalFilesystem; |
| 91 | |
| 92 | $root = sys_get_temp_dir() . '/toolkit-' . uniqid(); |
| 93 | $fs = LocalFilesystem::create( $root ); |
| 94 | |
| 95 | $fs->mkdir( '/uploads', array( 'recursive' => true ) ); |
| 96 | $fs->put_contents( '/uploads/note.txt', 'Hi from local disk.' ); |
| 97 | |
| 98 | echo $fs->get_contents( '/uploads/../uploads/note.txt' ) . "\n"; |
| 99 | |
| 100 | $fs->rmdir( '/', array( 'recursive' => true ) ); |
| 101 | echo "exists after cleanup? " . ( is_dir( $root ) ? 'yes' : 'no' ) . "\n"; |
| 102 | ``` |
| 103 | |
| 104 | <!-- expected-output --> |
| 105 | ``` |
| 106 | Hi from local disk. |
| 107 | exists after cleanup? no |
| 108 | ``` |
| 109 | |
| 110 | ## SQLite as a portable file store |
| 111 | |
| 112 | <p>The whole tree lives in one SQLite database file. Use it for self-contained scratch storage that survives process boundaries without leaving loose files behind. This backend requires PHP's <code>sqlite3</code> extension.</p> |
| 113 | |
| 114 | <!-- snippet: |
| 115 | filename: sqlite.php |
| 116 | runnable: true |
| 117 | --> |
| 118 | ```php |
| 119 | <?php |
| 120 | require '/php-toolkit/vendor/autoload.php'; |
| 121 | |
| 122 | use WordPress\Filesystem\SQLiteFilesystem; |
| 123 | |
| 124 | $fs = SQLiteFilesystem::create( ':memory:' ); |
| 125 | $fs->mkdir( '/posts', array( 'recursive' => true ) ); |
| 126 | for ( $i = 1; $i <= 3; $i++ ) { |
| 127 | $fs->put_contents( "/posts/post-{$i}.md", "# Post {$i}\n\nBody {$i}." ); |
| 128 | } |
| 129 | |
| 130 | foreach ( $fs->ls( '/posts' ) as $name ) { |
| 131 | $first = strtok( $fs->get_contents( '/posts/' . $name ), "\n" ); |
| 132 | echo "{$name}: {$first}\n"; |
| 133 | } |
| 134 | ``` |
| 135 | |
| 136 | <!-- expected-output --> |
| 137 | ``` |
| 138 | post-1.md: # Post 1 |
| 139 | post-2.md: # Post 2 |
| 140 | post-3.md: # Post 3 |
| 141 | ``` |
| 142 | |
| 143 | ## Copy a tree across backends |
| 144 | |
| 145 | <p>The killer composability move: <code>copy_between_filesystems()</code> streams files chunk-by-chunk from any source to any target. Pull a ZIP into SQLite, snapshot SQLite to disk, mirror disk into RAM — all the same call, with the relevant backend extensions available.</p> |
| 146 | |
| 147 | <!-- snippet: |
| 148 | filename: cross-backend-copy.php |
| 149 | runnable: true |
| 150 | --> |
| 151 | ```php |
| 152 | <?php |
| 153 | require '/php-toolkit/vendor/autoload.php'; |
| 154 | |
| 155 | use WordPress\Filesystem\InMemoryFilesystem; |
| 156 | use WordPress\Filesystem\LocalFilesystem; |
| 157 | use WordPress\Filesystem\SQLiteFilesystem; |
| 158 | use function WordPress\Filesystem\copy_between_filesystems; |
| 159 | |
| 160 | $root = sys_get_temp_dir() . '/copytree-' . uniqid(); |
| 161 | $local = LocalFilesystem::create( $root ); |
| 162 | $local->mkdir( '/site/posts', array( 'recursive' => true ) ); |
| 163 | $local->put_contents( '/site/posts/2024-01.md', '# Hello 2024' ); |
| 164 | $local->put_contents( '/site/index.html', '<h1>Home</h1>' ); |
| 165 | |
| 166 | $sqlite = SQLiteFilesystem::create( ':memory:' ); |
| 167 | copy_between_filesystems( array( |
| 168 | 'source_filesystem' => $local, |
| 169 | 'source_path' => '/site', |
| 170 | 'target_filesystem' => $sqlite, |
| 171 | 'target_path' => '/snapshot', |
| 172 | ) ); |
| 173 | |
| 174 | $mem = InMemoryFilesystem::create(); |
| 175 | copy_between_filesystems( array( |
| 176 | 'source_filesystem' => $sqlite, |
| 177 | 'source_path' => '/snapshot', |
| 178 | 'target_filesystem' => $mem, |
| 179 | 'target_path' => '/copy', |
| 180 | ) ); |
| 181 | |
| 182 | echo "in memory after two copies:\n"; |
| 183 | echo " posts: " . implode( ', ', $mem->ls( '/copy/posts' ) ) . "\n"; |
| 184 | echo " index: " . $mem->get_contents( '/copy/index.html' ) . "\n"; |
| 185 | |
| 186 | $local->rmdir( '/', array( 'recursive' => true ) ); |
| 187 | ``` |
| 188 | |
| 189 | <!-- expected-output --> |
| 190 | ``` |
| 191 | in memory after two copies: |
| 192 | posts: 2024-01.md |
| 193 | index: <h1>Home</h1> |
| 194 | ``` |
| 195 | |
| 196 | ## Atomic write via tempfile rename |
| 197 | |
| 198 | <p>Write to a sibling tempfile, then rename — that's how you avoid leaving a half-written file on crash. <code>rename()</code> is atomic within a single filesystem.</p> |
| 199 | |
| 200 | <!-- snippet: |
| 201 | filename: atomic-write.php |
| 202 | runnable: true |
| 203 | --> |
| 204 | ```php |
| 205 | <?php |
| 206 | require '/php-toolkit/vendor/autoload.php'; |
| 207 | |
| 208 | use WordPress\Filesystem\Filesystem; |
| 209 | use WordPress\Filesystem\LocalFilesystem; |
| 210 | |
| 211 | function atomic_put_contents( Filesystem $fs, $path, $bytes ) { |
| 212 | $tmp = $path . '.tmp.' . bin2hex( random_bytes( 4 ) ); |
| 213 | $fs->put_contents( $tmp, $bytes ); |
| 214 | $fs->rename( $tmp, $path ); |
| 215 | } |
| 216 | |
| 217 | $root = sys_get_temp_dir() . '/atomic-' . uniqid(); |
| 218 | $fs = LocalFilesystem::create( $root ); |
| 219 | |
| 220 | $fs->put_contents( '/config.json', '{"v":1}' ); |
| 221 | atomic_put_contents( $fs, '/config.json', '{"v":2}' ); |
| 222 | |
| 223 | echo "config: " . $fs->get_contents( '/config.json' ) . "\n"; |
| 224 | echo "no .tmp leftovers: " . count( $fs->ls( '/' ) ) . " entries in root\n"; |
| 225 | |
| 226 | $fs->rmdir( '/', array( 'recursive' => true ) ); |
| 227 | ``` |
| 228 | |
| 229 | <!-- expected-output --> |
| 230 | ``` |
| 231 | config: {"v":2} |
| 232 | no .tmp leftovers: 1 entries in root |
| 233 | ``` |
| 234 | |
| 235 | ## Path helpers that behave the same on Windows |
| 236 | |
| 237 | <p>Unix path semantics apply on every host OS. This matters for abstract paths such as a SQLite key or a ZIP entry name because those paths do not live on a real drive.</p> |
| 238 | |
| 239 | <!-- snippet: |
| 240 | filename: path-helpers.php |
| 241 | runnable: true |
| 242 | --> |
| 243 | ```php |
| 244 | <?php |
| 245 | require '/php-toolkit/vendor/autoload.php'; |
| 246 | |
| 247 | use function WordPress\Filesystem\wp_join_unix_paths; |
| 248 | use function WordPress\Filesystem\wp_unix_dirname; |
| 249 | use function WordPress\Filesystem\wp_unix_path_resolve_dots; |
| 250 | |
| 251 | echo wp_join_unix_paths( '/var/www', '/site/', '/index.php' ) . "\n"; |
| 252 | echo wp_unix_dirname( '/a/b/c/d.txt', 2 ) . "\n"; |
| 253 | echo wp_unix_path_resolve_dots( '/a/b/../c/./d/../e' ) . "\n"; |
| 254 | ``` |
| 255 | |
| 256 | <!-- expected-output --> |
| 257 | ``` |
| 258 | /var/www/site/index.php |
| 259 | /a/b |
| 260 | a/c/e |
| 261 | ``` |
| 262 |