Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
71.15% |
74 / 104 |
|
0.00% |
0 / 4 |
CRAP | |
0.00% |
0 / 1 |
| FileCopier | |
71.15% |
74 / 104 |
|
0.00% |
0 / 4 |
158.27 | |
0.00% |
0 / 1 |
| ensureDirectory | |
64.71% |
11 / 17 |
|
0.00% |
0 / 1 |
16.32 | |||
| copyOne | |
72.50% |
58 / 80 |
|
0.00% |
0 / 1 |
95.92 | |||
| applyDirModeFromSource | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| isIgnorable | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Copies one file within the time budget, resumably, with a hardlink fast path. |
| 4 | * |
| 5 | * @package NewfoldLabs\WP\Module\Staging |
| 6 | */ |
| 7 | |
| 8 | namespace NewfoldLabs\WP\Module\Staging\Copy; |
| 9 | |
| 10 | /** |
| 11 | * Moves a single manifest entry into place. Three behaviours, in order: |
| 12 | * |
| 13 | * 1. Hardlink fast path (deploy only): if a file at the link-dest (current production) matches the |
| 14 | * source by size + mtime, hardlink it instead of copying bytes. This is the per-file analogue of |
| 15 | * `rsync --link-dest`: unchanged files cost no bytes. |
| 16 | * 2. Resumable byte copy: otherwise stream the bytes in chunks, stopping cleanly at the deadline and |
| 17 | * returning PARTIAL with the offset reached, so a file larger than one budget resumes mid-file |
| 18 | * next call. The destination is truncated to the checkpointed offset before appending, so a dead |
| 19 | * request can never corrupt the file. |
| 20 | * 3. Verify: on completion the destination's size must equal the manifest size, and its mtime is set |
| 21 | * to the source mtime so a later deploy's delta still recognises it as unchanged. |
| 22 | * |
| 23 | * Pure PHP (no WordPress); unit-testable against temp dirs. |
| 24 | */ |
| 25 | class FileCopier { |
| 26 | |
| 27 | const RESULT_DONE = 'done'; |
| 28 | const RESULT_PARTIAL = 'partial'; |
| 29 | const RESULT_SKIP = 'skip'; |
| 30 | const RESULT_ERROR = 'error'; |
| 31 | |
| 32 | /** |
| 33 | * Bytes moved per read/write iteration. |
| 34 | */ |
| 35 | const CHUNK_BYTES = 1048576; // 1 MiB. |
| 36 | |
| 37 | /** |
| 38 | * Maximum mid-file re-snapshots from offset 0 before pinning a growing file's target size. |
| 39 | */ |
| 40 | const MAX_RESNAPSHOTS = 3; |
| 41 | |
| 42 | /** |
| 43 | * Ensure one directory entry from the manifest exists on the destination tree. |
| 44 | * |
| 45 | * @param string $src Absolute source directory path. |
| 46 | * @param string $dest Absolute destination directory path. |
| 47 | * @param int $mtime Expected mtime from the manifest. |
| 48 | * @param string $source_root Absolute content root the copy reads from. |
| 49 | * @return string One of the RESULT_* constants. |
| 50 | */ |
| 51 | public static function ensureDirectory( $src, $dest, $mtime, $source_root ) { |
| 52 | if ( ! is_dir( $src ) ) { |
| 53 | $parent_ok = is_dir( dirname( $src ) ); |
| 54 | $root_ok = is_dir( $source_root ); |
| 55 | return ( $parent_ok && $root_ok ) ? self::RESULT_SKIP : self::RESULT_ERROR; |
| 56 | } |
| 57 | |
| 58 | $dest_dir = dirname( $dest ); |
| 59 | if ( ! is_dir( $dest_dir ) ) { |
| 60 | if ( ! mkdir( $dest_dir, 0755, true ) && ! is_dir( $dest_dir ) ) { |
| 61 | return self::RESULT_ERROR; |
| 62 | } |
| 63 | self::applyDirModeFromSource( dirname( $src ), $dest_dir ); |
| 64 | } |
| 65 | if ( is_dir( $dest ) ) { |
| 66 | self::applyDirModeFromSource( $src, $dest ); |
| 67 | } elseif ( ! mkdir( $dest, 0755, false ) && ! is_dir( $dest ) ) { |
| 68 | return self::RESULT_ERROR; |
| 69 | } else { |
| 70 | self::applyDirModeFromSource( $src, $dest ); |
| 71 | } |
| 72 | if ( $mtime > 0 ) { |
| 73 | @touch( $dest, $mtime ); |
| 74 | } |
| 75 | return self::RESULT_DONE; |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Copy one file. |
| 80 | * |
| 81 | * @param string $src Absolute source path. |
| 82 | * @param string $dest Absolute destination path. |
| 83 | * @param int $size In/out: expected size (manifest or a persisted re-snapshot). |
| 84 | * @param int $mtime In/out: expected mtime (manifest or a persisted re-snapshot). |
| 85 | * @param string|null $link_cand Absolute hardlink candidate (current production copy), or null. |
| 86 | * @param int $offset In/out: bytes already written; updated as copying proceeds. |
| 87 | * @param float $deadline microtime(true) to stop at. |
| 88 | * @param string $source_root Absolute content root the copy reads from. Used to tell a single |
| 89 | * file genuinely vanishing (safe to skip) from the whole tree |
| 90 | * disappearing (catastrophic: fail closed). |
| 91 | * @param int $resnapshots In/out: mid-file re-snapshot count for continuously changing files. |
| 92 | * @return string One of the RESULT_* constants. |
| 93 | */ |
| 94 | public static function copyOne( $src, $dest, &$size, &$mtime, $link_cand, &$offset, $deadline, $source_root, &$resnapshots = 0 ) { |
| 95 | if ( ! file_exists( $src ) ) { |
| 96 | // The source is gone. Distinguish a single file genuinely removed between walk and copy (safe |
| 97 | // to skip) from the whole subtree or content root having vanished -- e.g. a concurrent clone |
| 98 | // `rm -rf`-ing staging mid-copy, or an EACCES/ESTALE that hides a tree still on disk. SKIPping |
| 99 | // a file that is really still there makes the finalize swap DELETE it from production, so skip |
| 100 | // only when the immediate parent dir AND the content root both still exist; else fail CLOSED. |
| 101 | $parent_ok = is_dir( dirname( $src ) ); |
| 102 | $root_ok = is_dir( $source_root ); |
| 103 | return ( $parent_ok && $root_ok ) ? self::RESULT_SKIP : self::RESULT_ERROR; |
| 104 | } |
| 105 | |
| 106 | $dest_dir = dirname( $dest ); |
| 107 | if ( ! is_dir( $dest_dir ) ) { |
| 108 | if ( ! mkdir( $dest_dir, 0755, true ) && ! is_dir( $dest_dir ) ) { |
| 109 | return self::RESULT_ERROR; |
| 110 | } |
| 111 | self::applyDirModeFromSource( dirname( $src ), $dest_dir ); |
| 112 | } |
| 113 | |
| 114 | // 1. Hardlink fast path. |
| 115 | // |
| 116 | // The comparison is production against the source AS IT IS NOW, not against the size + mtime |
| 117 | // the manifest recorded at enumeration. The copy phase can run for hours, so a staging file |
| 118 | // edited after the walk still matches the manifest -- and so did production, which meant the |
| 119 | // edit was hardlinked away and silently not shipped. The byte copy below re-snapshots for |
| 120 | // exactly this reason; the two paths have to agree about which version of the file a deploy |
| 121 | // ships, or the answer depends on whether a hardlink happened to be possible. |
| 122 | if ( null !== $link_cand && 0 === $offset && is_file( $link_cand ) && ! is_link( $link_cand ) ) { |
| 123 | clearstatcache( true, $src ); |
| 124 | clearstatcache( true, $link_cand ); |
| 125 | $src_size = filesize( $src ); |
| 126 | $src_mtime = filemtime( $src ); |
| 127 | if ( false !== $src_size && false !== $src_mtime |
| 128 | && filesize( $link_cand ) === $src_size && filemtime( $link_cand ) === $src_mtime ) { |
| 129 | if ( is_file( $dest ) || is_link( $dest ) ) { |
| 130 | @unlink( $dest ); |
| 131 | } |
| 132 | if ( @link( $link_cand, $dest ) ) { |
| 133 | return self::RESULT_DONE; // Shares the inode (and the mtime); nothing to verify. |
| 134 | } |
| 135 | // Hardlink failed (cross-device, etc.); fall through to a byte copy. |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // 2. Resumable byte copy. |
| 140 | $in = fopen( $src, 'rb' ); |
| 141 | if ( false === $in ) { |
| 142 | // Enumerated but now unreadable. Tolerate the legacy htaccess exception (unreadable |
| 143 | // .htaccess.* backups); otherwise fail CLOSED rather than silently omit the file. |
| 144 | return self::isIgnorable( $src ) ? self::RESULT_SKIP : self::RESULT_ERROR; |
| 145 | } |
| 146 | // Snapshot integrity: the manifest recorded this file's size + mtime at enumeration. If either |
| 147 | // has changed, the file was rewritten/appended/truncated since, so copying exactly the manifest |
| 148 | // `$size` would ship a frankenfile (a prefix of new content that passes the size check, or a |
| 149 | // short read). Re-snapshot to the CURRENT size + mtime and copy THAT instead: that ships a |
| 150 | // self-consistent version of the file (its state right now) and lets the deploy COMPLETE, rather |
| 151 | // than failing every retry while a live file (regenerated CSS, an active log) keeps changing. |
| 152 | // Restart from offset 0 so no stale partial sized to the old snapshot is kept. CopyEngine persists |
| 153 | // the re-snapshotted size + mtime in the cursor so the next burst passes them back in and a |
| 154 | // large, stable post-change file can resume mid-file instead of re-snapshotting every burst. |
| 155 | $sstat = fstat( $in ); |
| 156 | if ( false === $sstat ) { |
| 157 | fclose( $in ); |
| 158 | return self::RESULT_ERROR; |
| 159 | } |
| 160 | if ( (int) $sstat['size'] !== (int) $size || (int) $sstat['mtime'] !== (int) $mtime ) { |
| 161 | if ( $offset > 0 && $resnapshots >= self::MAX_RESNAPSHOTS ) { |
| 162 | // A hot file (e.g. an active debug.log) has changed across every burst. If it only grew, |
| 163 | // keep copying up to the pinned snapshot size so the job finishes without livelocking; if |
| 164 | // it shrank below our progress, fail this attempt so CopyEngine's retry cap bounds it. |
| 165 | if ( (int) $sstat['size'] < (int) $size ) { |
| 166 | fclose( $in ); |
| 167 | return self::RESULT_ERROR; |
| 168 | } |
| 169 | } else { |
| 170 | if ( $offset > 0 ) { |
| 171 | ++$resnapshots; |
| 172 | } |
| 173 | $size = (int) $sstat['size']; |
| 174 | $mtime = (int) $sstat['mtime']; |
| 175 | $offset = 0; |
| 176 | } |
| 177 | } |
| 178 | // At offset 0, remove any existing destination first. If $dest is a hardlink to the production |
| 179 | // inode (a re-processed entry, or a stale -new), writing through it would truncate and corrupt |
| 180 | // live production; unlinking breaks the link so we write a fresh, independent file. |
| 181 | if ( 0 === $offset && ( is_file( $dest ) || is_link( $dest ) ) ) { |
| 182 | @unlink( $dest ); |
| 183 | clearstatcache( true, $dest ); |
| 184 | if ( is_file( $dest ) || is_link( $dest ) ) { |
| 185 | // The unlink failed and the dest is still there. If it is a hardlink to production, the |
| 186 | // byte copy below would write THROUGH it and corrupt live production. Fail CLOSED. |
| 187 | fclose( $in ); |
| 188 | return self::RESULT_ERROR; |
| 189 | } |
| 190 | } |
| 191 | // 'cb' = create/keep, do not truncate. Cut any partial tail past the checkpointed offset, then |
| 192 | // position both handles at it, so resume is exact and a dead request leaves no stray bytes. |
| 193 | $out = fopen( $dest, 'cb' ); |
| 194 | if ( false === $out ) { |
| 195 | fclose( $in ); |
| 196 | return self::RESULT_ERROR; |
| 197 | } |
| 198 | if ( ! ftruncate( $out, $offset ) || -1 === fseek( $out, $offset ) || ( $offset > 0 && -1 === fseek( $in, $offset ) ) ) { |
| 199 | fclose( $in ); |
| 200 | fclose( $out ); |
| 201 | return self::RESULT_ERROR; |
| 202 | } |
| 203 | |
| 204 | // Read exactly up to the manifest size, never past it: a file that GREW after enumeration must |
| 205 | // still yield a `$size`-byte copy (snapshot semantics), and a file that SHRANK hits an early |
| 206 | // EOF which we surface as an error rather than ship a short file. The deadline is checked after |
| 207 | // each chunk, so every burst makes at least one chunk of progress (no livelock). |
| 208 | $result = self::RESULT_DONE; |
| 209 | while ( $offset < $size ) { |
| 210 | $to_read = (int) min( self::CHUNK_BYTES, $size - $offset ); |
| 211 | $buf = fread( $in, $to_read ); |
| 212 | if ( false === $buf || '' === $buf ) { |
| 213 | $result = self::RESULT_ERROR; // Read error or unexpected EOF (source shrank). |
| 214 | break; |
| 215 | } |
| 216 | $written = fwrite( $out, $buf ); |
| 217 | if ( false === $written || $written < strlen( $buf ) ) { |
| 218 | // Short write = out of space / over quota. Do not advance the offset past what landed. |
| 219 | $offset += is_int( $written ) ? $written : 0; |
| 220 | $result = self::RESULT_ERROR; |
| 221 | break; |
| 222 | } |
| 223 | $offset += $written; |
| 224 | if ( $offset < $size && microtime( true ) >= $deadline ) { |
| 225 | $result = self::RESULT_PARTIAL; |
| 226 | break; |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | fflush( $out ); |
| 231 | fclose( $in ); |
| 232 | fclose( $out ); |
| 233 | |
| 234 | if ( self::RESULT_DONE !== $result ) { |
| 235 | return $result; |
| 236 | } |
| 237 | |
| 238 | // 3. Verify + stamp mode and mtime. |
| 239 | $mode = (int) $sstat['mode'] & 0777; |
| 240 | if ( $mode > 0 ) { |
| 241 | @chmod( $dest, $mode ); |
| 242 | } |
| 243 | @touch( $dest, $mtime ); |
| 244 | clearstatcache( true, $dest ); |
| 245 | if ( filesize( $dest ) !== $size ) { |
| 246 | return self::RESULT_ERROR; |
| 247 | } |
| 248 | return self::RESULT_DONE; |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * Match a newly created destination directory to its source's permission bits while keeping owner |
| 253 | * rwx (0700) so files can still be written into it during the copy and sweeps can remove `-old`. |
| 254 | * |
| 255 | * @param string $src_dir Absolute source directory. |
| 256 | * @param string $dest_dir Absolute destination directory just created. |
| 257 | * @return void |
| 258 | */ |
| 259 | private static function applyDirModeFromSource( $src_dir, $dest_dir ) { |
| 260 | clearstatcache( true, $src_dir ); |
| 261 | if ( ! is_dir( $src_dir ) ) { |
| 262 | return; |
| 263 | } |
| 264 | $raw_mode = fileperms( $src_dir ) & 0777; |
| 265 | if ( $raw_mode > 0 ) { |
| 266 | @chmod( $dest_dir, $raw_mode | 0700 ); |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Whether an unreadable source file may be skipped instead of failing the job. Matches the legacy |
| 272 | * worker's IGNORE_PATTERNS=("htaccess.") tolerance for unreadable .htaccess.* backup files. |
| 273 | * |
| 274 | * @param string $path Absolute source path. |
| 275 | * @return bool |
| 276 | */ |
| 277 | private static function isIgnorable( $path ) { |
| 278 | return false !== strpos( basename( $path ), 'htaccess.' ); |
| 279 | } |
| 280 | } |